diff --git a/docs/zh-CN/components/form/index.md b/docs/zh-CN/components/form/index.md index 15cdbdf5b37..677afecf716 100755 --- a/docs/zh-CN/components/form/index.md +++ b/docs/zh-CN/components/form/index.md @@ -2521,3 +2521,45 @@ Form 支持轮询初始化接口,步骤如下: ] } ``` + +### clearError + +> 6.11.1 及以上版本 +> 清除表单校验产生的错误状态 + +```schema +{ + type: 'page', + title: '清除表单错误', + body: [ + { + type: 'form', + title: '表单', + id: 'form', + body: [ + { + type: 'input-text', + label: '必填项', + name: 'required', + required: true + } + ] + }, + { + type: 'button', + label: '清除错误', + level: 'primary', + onEvent: { + click: { + actions: [ + { + actionType: 'clearError', + componentId: 'form' + } + ] + } + } + } + ] +} +``` diff --git a/packages/amis-core/src/renderers/Form.tsx b/packages/amis-core/src/renderers/Form.tsx index 69623a7601b..22a3e8a456b 100644 --- a/packages/amis-core/src/renderers/Form.tsx +++ b/packages/amis-core/src/renderers/Form.tsx @@ -45,7 +45,11 @@ import { import {IComboStore} from '../store/combo'; import {dataMapping} from '../utils/tpl-builtin'; -import {isApiOutdated, isEffectiveApi} from '../utils/api'; +import { + isApiOutdated, + isEffectiveApi, + shouldBlockedBySendOnApi +} from '../utils/api'; import LazyComponent from '../components/LazyComponent'; import {isAlive} from 'mobx-state-tree'; @@ -1319,7 +1323,7 @@ export default class Form extends React.Component { } // 走到这里代表校验成功了 - dispatchEvent('validateSucc', this.props.data); + dispatchEvent('validateSucc', createObject(this.props.data, values)); if (target) { this.submitToTarget(filterTarget(target, values), values); @@ -1439,6 +1443,9 @@ export default class Form extends React.Component { __response: response }); }); + } else if (shouldBlockedBySendOnApi(action.api || api, values)) { + // api存在,但是不满足sendOn时,走这里,不派发submitSucc事件 + return; } else { clearPersistDataAfterSubmit && store.clearLocalPersistData(); // type为submit,但是没有配api以及target时,只派发事件 @@ -2314,30 +2321,7 @@ export default class Form extends React.Component { } } -@Renderer({ - type: 'form', - storeType: FormStore.name, - isolateScope: true, - storeExtendsData: (props: any) => props.inheritData, - shouldSyncSuperStore: (store, props, prevProps) => { - // 如果是 QuickEdit,让 store 同步 __super 数据。 - if ( - props.quickEditFormRef && - props.onQuickChange && - (isObjectShallowModified(prevProps.data, props.data) || - isObjectShallowModified(prevProps.data.__super, props.data.__super) || - isObjectShallowModified( - prevProps.data.__super?.__super, - props.data.__super?.__super - )) - ) { - return true; - } - - return undefined; - } -}) -export class FormRenderer extends Form { +export class _FormRenderer extends Form { static contextType = ScopedContext; constructor(props: FormProps, context: IScopedContext) { @@ -2408,6 +2392,8 @@ export class FormRenderer extends Form { ); }) ); + } else if (action.actionType === 'clearError') { + return super.clearErrors(); } else { return super.handleAction(e, action, ctx, throwErrors, delegate); } @@ -2528,3 +2514,28 @@ export class FormRenderer extends Form { return this.getValues(); } } + +@Renderer({ + type: 'form', + storeType: FormStore.name, + isolateScope: true, + storeExtendsData: (props: any) => props.inheritData, + shouldSyncSuperStore: (store, props, prevProps) => { + // 如果是 QuickEdit,让 store 同步 __super 数据。 + if ( + props.quickEditFormRef && + props.onQuickChange && + (isObjectShallowModified(prevProps.data, props.data) || + isObjectShallowModified(prevProps.data.__super, props.data.__super) || + isObjectShallowModified( + prevProps.data.__super?.__super, + props.data.__super?.__super + )) + ) { + return true; + } + + return undefined; + } +}) +export class FormRenderer extends _FormRenderer {} diff --git a/packages/amis-core/src/store/form.ts b/packages/amis-core/src/store/form.ts index 5f1828b7ed7..ccd451e9a2a 100644 --- a/packages/amis-core/src/store/form.ts +++ b/packages/amis-core/src/store/form.ts @@ -549,9 +549,11 @@ export const FormStore = ServiceStore.named('FormStore') // 10s 内不要重复弹同一个错误 const toastValidateError = throttle( - msg => { + (msg, validateError?: ValidateError) => { const env = getEnv(self); - env.notify('error', msg); + env.notify('error', msg, { + validateError + }); }, 10000, { @@ -699,7 +701,18 @@ export const FormStore = ServiceStore.named('FormStore') dispatcher = yield dispatcher; } if (!dispatcher?.prevented) { - msg && toastValidateError(msg); + const validateError = new ValidateError( + failedMessage || self.__('Form.validateFailed'), + self.errors, + { + items: self.items, + msg: { + customMsg: failedMessage, + defaultMsg: self.__('Form.validateFailed') + } + } + ); + msg && toastValidateError(msg, validateError); } } diff --git a/packages/amis-core/src/types.ts b/packages/amis-core/src/types.ts index b26d7fb2218..49405eb9d86 100644 --- a/packages/amis-core/src/types.ts +++ b/packages/amis-core/src/types.ts @@ -2,7 +2,7 @@ import type {JSONSchema7} from 'json-schema'; import {ListenerAction} from './actions/Action'; import {debounceConfig, trackConfig} from './utils/renderer-event'; -import type {TestIdBuilder} from './utils/helper'; +import type {TestIdBuilder, ValidateError} from './utils/helper'; import {AnimationsProps} from './utils/animations'; export interface Option { @@ -373,7 +373,8 @@ export interface ActionObject extends ButtonObject { | 'initDrag' | 'cancelDrag' | 'toggleExpanded' - | 'setExpanded'; + | 'setExpanded' + | 'clearError'; api?: BaseApiObject | string; asyncApi?: BaseApiObject | string; @@ -542,6 +543,7 @@ export type ToastConf = { className?: string; items?: Array; useMobileUI?: boolean; + validateError?: ValidateError; }; export interface OptionProps { diff --git a/packages/amis-core/src/utils/api.ts b/packages/amis-core/src/utils/api.ts index 57545529a02..1f80bca5ea1 100644 --- a/packages/amis-core/src/utils/api.ts +++ b/packages/amis-core/src/utils/api.ts @@ -947,6 +947,16 @@ export function isEffectiveApi( return false; } +// 判断api是否存在,且SendOn为不发送 +export function shouldBlockedBySendOnApi(api?: Api, data?: any) { + if (isObject(api) && (api as ApiObject).url) { + if ((api as ApiObject).sendOn && data) { + return !evalExpression((api as ApiObject).sendOn as string, data); + } + } + return false; +} + export function isSameApi( apiA: ApiObject | ApiCacheConfig, apiB: ApiObject | ApiCacheConfig diff --git a/packages/amis-core/src/utils/helper.ts b/packages/amis-core/src/utils/helper.ts index a6f30c67502..0a384d699a0 100644 --- a/packages/amis-core/src/utils/helper.ts +++ b/packages/amis-core/src/utils/helper.ts @@ -1762,15 +1762,18 @@ export class SkipOperation extends Error {} export class ValidateError extends Error { name: 'ValidateError'; detail: {[propName: string]: Array | string}; + rawError?: {[propName: string]: any}; constructor( message: string, - error: {[propName: string]: Array | string} + error: {[propName: string]: Array | string}, + rawError?: {[propName: string]: any} ) { super(); this.name = 'ValidateError'; this.message = message; this.detail = error; + this.rawError = rawError; } } diff --git a/packages/amis-editor/src/plugin/Button.tsx b/packages/amis-editor/src/plugin/Button.tsx index d3462d76c80..236250b97f6 100644 --- a/packages/amis-editor/src/plugin/Button.tsx +++ b/packages/amis-editor/src/plugin/Button.tsx @@ -282,7 +282,12 @@ export class ButtonPlugin extends BasePlugin { name: 'rightIcon', label: '右侧图标' }), - getSchemaTpl('badge') + getSchemaTpl('badge'), + getSchemaTpl('switch', { + name: 'disabledOnAction', + label: '动作完成前禁用', + value: false + }) ] }, getSchemaTpl('status', { diff --git a/packages/amis-editor/src/plugin/Form/Form.tsx b/packages/amis-editor/src/plugin/Form/Form.tsx index 4ad054955e9..1d28cad37f8 100644 --- a/packages/amis-editor/src/plugin/Form/Form.tsx +++ b/packages/amis-editor/src/plugin/Form/Form.tsx @@ -388,6 +388,11 @@ export class FormPlugin extends BasePlugin { actionType: 'setValue', description: '触发组件数据更新', ...getActionCommonProps('setValue') + }, + { + actionLabel: '清除校验状态', + actionType: 'clearError', + description: '清除表单校验产生的错误状态' } ]; @@ -651,7 +656,9 @@ export class FormPlugin extends BasePlugin { /\/crud2\/filter\/form$/.test(context.path) || /body\/0\/filter$/.test(context.schemaPath); /** 表单是否位于Dialog内 */ - const isInDialog: boolean = context.path?.includes?.('dialog/'); + const isInDialog: boolean = + context.path?.includes?.('dialog/') || + context.path?.includes?.('drawer/'); /** 是否使用Panel包裹 */ const isWrapped = 'this.wrapWithPanel !== false'; const justifyLayout = (left: number = 2) => ({ diff --git a/packages/amis-editor/src/plugin/Tabs.tsx b/packages/amis-editor/src/plugin/Tabs.tsx index f7d43fec1bd..bb57d2e385d 100644 --- a/packages/amis-editor/src/plugin/Tabs.tsx +++ b/packages/amis-editor/src/plugin/Tabs.tsx @@ -49,7 +49,14 @@ export class TabsPlugin extends BasePlugin { body: [] } ], - mountOnEnter: true + mountOnEnter: true, + themeCss: { + titleControlClassName: { + 'font:default': { + 'text-align': 'center' + } + } + } }; previewSchema = { ...this.scaffold diff --git a/packages/amis/__tests__/renderers/Form/formitem.test.tsx b/packages/amis/__tests__/renderers/Form/formitem.test.tsx index f069dcfcce3..13aafb4cc1b 100644 --- a/packages/amis/__tests__/renderers/Form/formitem.test.tsx +++ b/packages/amis/__tests__/renderers/Form/formitem.test.tsx @@ -1,7 +1,7 @@ import React = require('react'); import {render, fireEvent, cleanup} from '@testing-library/react'; import '../../../src'; -import {render as amisRender} from '../../../src'; +import {render as amisRender, ValidateError} from '../../../src'; import {wait, makeEnv} from '../../helper'; import {clearStoresCache} from '../../../src'; import moment from 'moment'; @@ -61,7 +61,19 @@ test('Renderer:FormItem:validateApi:success', async () => { expect(onSubmit).not.toHaveBeenCalled(); await wait(300); - expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证'); + expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证', { + validateError: new ValidateError( + '依赖的部分字段没有通过验证', + {}, + { + items: [], + msg: { + customMsg: '依赖的部分字段没有通过验证', + defaultMsg: '依赖的部分字段没有通过验证' + } + } + ) + }); const input = container.querySelector('input[name=a]'); expect(input).toBeTruthy(); @@ -130,7 +142,19 @@ test('Renderer:FormItem:validateApi:failed', async () => { expect(onSubmit).not.toHaveBeenCalled(); await wait(300); - expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证'); + expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证', { + validateError: new ValidateError( + '依赖的部分字段没有通过验证', + {}, + { + items: [], + msg: { + customMsg: '依赖的部分字段没有通过验证', + defaultMsg: '依赖的部分字段没有通过验证' + } + } + ) + }); const input = container.querySelector('input[name=a]'); expect(input).toBeTruthy(); diff --git a/packages/amis/__tests__/renderers/Form/index.test.tsx b/packages/amis/__tests__/renderers/Form/index.test.tsx index 88875e890b0..ec79a33c8a7 100644 --- a/packages/amis/__tests__/renderers/Form/index.test.tsx +++ b/packages/amis/__tests__/renderers/Form/index.test.tsx @@ -1,6 +1,6 @@ import {render, fireEvent, cleanup, waitFor} from '@testing-library/react'; import '../../../src'; -import {render as amisRender} from '../../../src'; +import {render as amisRender, ValidateError} from '../../../src'; import {wait, makeEnv} from '../../helper'; import {clearStoresCache} from '../../../src'; @@ -103,7 +103,19 @@ test('Renderer:Form:valdiate', async () => { expect(container).toMatchSnapshot(); expect(onSubmit).not.toHaveBeenCalled(); - expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证'); + expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证', { + validateError: new ValidateError( + '依赖的部分字段没有通过验证', + {}, + { + items: [], + msg: { + customMsg: '依赖的部分字段没有通过验证', + defaultMsg: '依赖的部分字段没有通过验证' + } + } + ) + }); const input = container.querySelector('input[name=a]'); expect(input).toBeTruthy(); @@ -239,7 +251,19 @@ test('Renderer:Form:onValidate', async () => { expect(onValidate).toHaveBeenCalled(); expect(onValidate.mock.calls[0][0]).toMatchSnapshot(); - expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证'); + expect(notify).toHaveBeenCalledWith('error', '依赖的部分字段没有通过验证', { + validateError: new ValidateError( + '依赖的部分字段没有通过验证', + {}, + { + items: [], + msg: { + customMsg: '依赖的部分字段没有通过验证', + defaultMsg: '依赖的部分字段没有通过验证' + } + } + ) + }); fireEvent.click(getByText('Submit')); diff --git a/packages/amis/src/renderers/Action.tsx b/packages/amis/src/renderers/Action.tsx index 45e16ed7dd3..4e14302494a 100644 --- a/packages/amis/src/renderers/Action.tsx +++ b/packages/amis/src/renderers/Action.tsx @@ -159,6 +159,11 @@ export interface ButtonSchema extends BaseSchema { */ loadingOn?: string; + /** + * 是否在动作结束前禁用按钮 + */ + disabledOnAction?: boolean; + /** * 自定义事件处理函数 */ @@ -949,6 +954,10 @@ export type ActionRendererProps = RendererProps & export class ActionRenderer extends React.Component { static contextType = ScopedContext; + state = { + actionDisabled: false + }; + constructor(props: ActionRendererProps, scoped: IScopedContext) { super(props); @@ -981,34 +990,60 @@ export class ActionRenderer extends React.Component { e: React.MouseEvent | string | void | null, action: any ) { - const {env, onAction, data, ignoreConfirm, dispatchEvent, $schema} = - this.props; - let mergedData = data; + try { + const {env, onAction, data, ignoreConfirm, dispatchEvent, $schema} = + this.props; + let mergedData = data; + this.setState({actionDisabled: true}); + + if (action?.actionType === 'click' && isObject(action?.args)) { + mergedData = createObject(data, action.args); + } - if (action?.actionType === 'click' && isObject(action?.args)) { - mergedData = createObject(data, action.args); - } + const hasOnEvent = $schema.onEvent && Object.keys($schema.onEvent).length; + let confirmText: string = ''; + // 有些组件虽然要求这里忽略二次确认,但是如果配了事件动作还是需要在这里等待二次确认提交才可以 + if ( + this.props.showConfirmBox !== false && // 外部判断是否开启二次确认弹窗的验证,勿删 + (!ignoreConfirm || hasOnEvent) && + action.confirmText && + env.confirm && + (confirmText = filter(action.confirmText, mergedData)) + ) { + let confirmed = await env.confirm( + confirmText, + filter(action.confirmTitle, mergedData) || undefined + ); + if (confirmed) { + // 触发渲染器事件 + const rendererEvent = await dispatchEvent( + e as React.MouseEvent | string, + mergedData, + this // 保证renderer可以拿到,避免因交互设计导致的清空情况,例如crud内itemAction + ); + + // 阻止原有动作执行 + if (rendererEvent?.prevented) { + return; + } - const hasOnEvent = $schema.onEvent && Object.keys($schema.onEvent).length; - let confirmText: string = ''; - // 有些组件虽然要求这里忽略二次确认,但是如果配了事件动作还是需要在这里等待二次确认提交才可以 - if ( - this.props.showConfirmBox !== false && // 外部判断是否开启二次确认弹窗的验证,勿删 - (!ignoreConfirm || hasOnEvent) && - action.confirmText && - env.confirm && - (confirmText = filter(action.confirmText, mergedData)) - ) { - let confirmed = await env.confirm( - confirmText, - filter(action.confirmTitle, mergedData) || undefined - ); - if (confirmed) { + // 因为crud里面也会处理二次确认,所以如果按钮处理过了就跳过crud的二次确认 + await onAction( + e, + {...action, ignoreConfirm: !!hasOnEvent}, + mergedData, + undefined, + undefined, + rendererEvent + ); + } else if (action.countDown) { + throw new Error('cancel'); + } + } else { // 触发渲染器事件 const rendererEvent = await dispatchEvent( e as React.MouseEvent | string, - mergedData, - this // 保证renderer可以拿到,避免因交互设计导致的清空情况,例如crud内itemAction + mergedData ); // 阻止原有动作执行 @@ -1016,38 +1051,17 @@ export class ActionRenderer extends React.Component { return; } - // 因为crud里面也会处理二次确认,所以如果按钮处理过了就跳过crud的二次确认 await onAction( e, - {...action, ignoreConfirm: !!hasOnEvent}, + action, mergedData, undefined, undefined, rendererEvent ); - } else if (action.countDown) { - throw new Error('cancel'); - } - } else { - // 触发渲染器事件 - const rendererEvent = await dispatchEvent( - e as React.MouseEvent | string, - mergedData - ); - - // 阻止原有动作执行 - if (rendererEvent?.prevented) { - return; } - - await onAction( - e, - action, - mergedData, - undefined, - undefined, - rendererEvent - ); + } finally { + this.setState({actionDisabled: false}); } } @@ -1080,13 +1094,16 @@ export class ActionRenderer extends React.Component { } render() { - const {env, disabled, btnDisabled, loading, ...rest} = this.props; - + const {env, disabled, btnDisabled, disabledOnAction, loading, ...rest} = + this.props; + const {actionDisabled} = this.state; return ( () { +let supportStatic = () => { return function ( target: any, name: string, @@ -157,7 +157,7 @@ export function supportStatic() { }; return descriptor; }; -} +}; function renderStaticDateTypes(props: any) { const { @@ -178,3 +178,15 @@ function renderStaticDateTypes(props: any) { valueFormat: valueFormat || format }); } + +const overrideSupportStatic = ( + overrideFunc: () => ( + target: any, + name: string, + descriptor: TypedPropertyDescriptor + ) => TypedPropertyDescriptor +) => { + supportStatic = overrideFunc; +}; + +export {supportStatic, overrideSupportStatic}; diff --git a/packages/amis/src/renderers/Panel.tsx b/packages/amis/src/renderers/Panel.tsx index 5d2ebf43fcf..d374c0e5af9 100644 --- a/packages/amis/src/renderers/Panel.tsx +++ b/packages/amis/src/renderers/Panel.tsx @@ -110,8 +110,9 @@ export interface PanelProps PanelSchema, 'type' | 'className' | 'panelClassName' | 'bodyClassName' > {} - -export default class Panel extends React.Component { +export default class Panel< + T extends PanelProps = PanelProps +> extends React.Component { static propsList: Array = [ 'header', 'actions', @@ -134,7 +135,7 @@ export default class Panel extends React.Component { collapsed: false }; - constructor(props: PanelProps) { + constructor(props: T) { super(props); props.mobileUI && props.collapsible && (this.state.collapsed = true); } diff --git a/packages/amis/src/renderers/Words.tsx b/packages/amis/src/renderers/Words.tsx index facf7a82a4e..de804f39b27 100644 --- a/packages/amis/src/renderers/Words.tsx +++ b/packages/amis/src/renderers/Words.tsx @@ -9,7 +9,8 @@ import { Option, getTreeAncestors, resolveVariableAndFilter, - labelToString + labelToString, + filter } from 'amis-core'; import {BaseSchema, SchemaObject} from '../Schema'; import {Tag} from 'amis-ui'; @@ -61,6 +62,11 @@ export interface WordsSchema extends BaseSchema { * 分割符 */ delimiter?: string; + + /** + * 标签模板 + */ + labelTpl?: string; } export interface WordsProps @@ -76,7 +82,8 @@ function getLabel( options = [], enableNodePath, hideNodePathLabel, - pathSeparator = '/' + pathSeparator = '/', + labelTpl }: any ): string { if (enableNodePath || (type === 'nested-select' && !hideNodePathLabel)) { @@ -91,7 +98,11 @@ function getLabel( }`; } - return labelToString(item[labelField]) || `选项${index}`; + const label = labelTpl + ? filter(labelTpl, item) + : labelToString(item[labelField]) || `选项${index}`; + + return label; } export class WordsField extends React.Component {