diff --git a/docs/zh-CN/components/form/index.md b/docs/zh-CN/components/form/index.md index a2d1464d9..0bd6a8a68 100755 --- a/docs/zh-CN/components/form/index.md +++ b/docs/zh-CN/components/form/index.md @@ -495,7 +495,7 @@ Form 支持轮询初始化接口,步骤如下: ```schema: scope="body" { "type": "form", - "initApi": "https://3xsw4ap8wah59.cfc-execute.bj.baidubce.com/api/amis-mock/saveForm", + "api": "https://3xsw4ap8wah59.cfc-execute.bj.baidubce.com/api/amis-mock/saveForm", "title": "用户信息", "controls": [ { @@ -607,6 +607,39 @@ Form 支持轮询初始化接口,步骤如下: 如果决定结束轮询的标识字段名不是 `finished`,请设置`finishedField`属性,比如:`"finishedField": "is_success"` +## 表单校验 + +一般可以通过在[表单项格式校验](./formitem#%E6%A0%BC%E5%BC%8F%E6%A0%A1%E9%AA%8C)中,配置校验规则完成校验,但是有时候,我们需要组合多个表单项实现一些校验,这时可以通过配置 `rules` 来实现组合校验。 + +例如下例,我们想校验 `a` 和 `b` 表单项不可以同时有值,否则就报错,则可以进行如下配置: + +```schema:scope="body" +{ + "type": "form", + "api": "https://3xsw4ap8wah59.cfc-execute.bj.baidubce.com/api/amis-mock/saveForm", + "rules": [ + { + "rule": "!(data.a && data.b)", + "message": "a 和 b 不能同时有值" + } + ], + "controls": [ + { + "type": "text", + "name": "a", + "label": "A" + }, + { + "type": "text", + "name": "b", + "label": "B" + } + ] +} +``` + +> `rule` 编写使用 [表达式](../../concepts/expression) + ## 重置表单 配置`"type": "reset"`或者`"actionType": "reset"`的按钮,可以实现点击重置表单项值。 @@ -827,47 +860,48 @@ Form 支持轮询初始化接口,步骤如下: ## 属性表 -| 属性名 | 类型 | 默认值 | 说明 | -| --------------------------- | ---------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| type | `string` | | `"form"` 指定为 Form 渲染器 | -| name | `string` | | 设置一个名字后,方便其他组件与其通信 | -| mode | `string` | `normal` | 表单展示方式,可以是:`normal`、`horizontal` 或者 `inline` | -| horizontal | `Object` | `{"left":"col-sm-2", "right":"col-sm-10", "offset":"col-sm-offset-2"}` | 当 mode 为 `horizontal` 时有用,用来控制 label | -| title | `string` | `"表单"` | Form 的标题 | -| submitText | `String` | `"提交"` | 默认的提交按钮名称,如果设置成空,则可以把默认按钮去掉。 | -| className | `string` | | 外层 Dom 的类名 | -| controls | Array<[表单项](./formitem)> | | Form 表单项集合 | -| actions | Array<[行为按钮](../action)> | | Form 提交按钮,成员为 Action | -| messages | `Object` | | 消息提示覆写,默认消息读取的是 API 返回的消息,但是在此可以覆写它。 | -| messages.fetchSuccess | `string` | | 获取成功时提示 | -| messages.fetchFailed | `string` | | 获取失败时提示 | -| messages.saveSuccess | `string` | | 保存成功时提示 | -| messages.saveFailed | `string` | | 保存失败时提示 | -| wrapWithPanel | `boolean` | `true` | 是否让 Form 用 panel 包起来,设置为 false 后,actions 将无效。 | -| panelClassName | `string` | | 外层 panel 的类名 | -| api | [API](../../types/api) | | Form 用来保存数据的 api。 | -| initApi | [API](../../types/api) | | Form 用来获取初始数据的 api。 | -| interval | `number` | `3000` | 刷新时间(最低 3000) | -| silentPolling | `boolean` | `false` | 配置刷新时是否显示加载动画 | -| stopAutoRefreshWhen | `string` | `""` | 通过[表达式](./Types.md#表达式) 来配置停止刷新的条件 | -| initAsyncApi | [API](../../types/api) | | Form 用来获取初始数据的 api,与 initApi 不同的是,会一直轮询请求该接口,直到返回 finished 属性为 true 才 结束。 | -| initFetch | `boolean` | `true` | 设置了 initApi 或者 initAsyncApi 后,默认会开始就发请求,设置为 false 后就不会起始就请求接口 | -| initFetchOn | `string` | | 用表达式来配置 | -| initFinishedField | `string` | `finished` | 设置了 initAsyncApi 后,默认会从返回数据的 data.finished 来判断是否完成,也可以设置成其他的 xxx,就会从 data.xxx 中获取 | -| initCheckInterval | `number` | `3000` | 设置了 initAsyncApi 以后,默认拉取的时间间隔 | -| asyncApi | [API](../../types/api) | | 设置此属性后,表单提交发送保存接口后,还会继续轮询请求该接口,直到返回 `finished` 属性为 `true` 才 结束。 | -| checkInterval | `number` | 3000 | 轮询请求的时间间隔,默认为 3 秒。设置 `asyncApi` 才有效 | -| finishedField | `string` | `"finished"` | 如果决定结束的字段名不是 `finished` 请设置此属性,比如 `is_success` | -| submitOnChange | `boolean` | `false` | 表单修改即提交 | -| submitOnInit | `boolean` | `false` | 初始就提交一次 | -| resetAfterSubmit | `boolean` | `false` | 提交后是否重置表单 | -| primaryField | `string` | `"id"` | 设置主键 id, 当设置后,检测表单是否完成时(asyncApi),只会携带此数据。 | -| target | `string` | | 默认表单提交自己会通过发送 api 保存数据,但是也可以设定另外一个 form 的 name 值,或者另外一个 `CRUD` 模型的 name 值。 如果 target 目标是一个 `Form` ,则目标 `Form` 会重新触发 `initApi`,api 可以拿到当前 form 数据。如果目标是一个 `CRUD` 模型,则目标模型会重新触发搜索,参数为当前 Form 数据。当目标是 `window` 时,会把当前表单的数据附带到页面地址上。 | -| redirect | `string` | | 设置此属性后,Form 保存成功后,自动跳转到指定页面。支持相对地址,和绝对地址(相对于组内的)。 | -| reload | `string` | | 操作完后刷新目标对象。请填写目标组件设置的 name 值,如果填写为 `window` 则让当前页面整体刷新。 | -| autoFocus | `boolean` | `false` | 是否自动聚焦。 | -| canAccessSuperData | `boolean` | `true` | 指定是否可以自动获取上层的数据并映射到表单项上 | -| persistData | `boolean` | `true` | 指定表单是否开启本地缓存 | -| clearPersistDataAfterSubmit | `boolean` | `true` | 指定表单提交成功后是否清除本地缓存 | -| trimValues | `boolean` | `false` | trim 当前表单项的每一个值 | -| promptPageLeave | `boolean` | `false` | form 还没保存,即将离开页面前是否弹框确认。 | +| 属性名 | 类型 | 默认值 | 说明 | +| --------------------------- | ----------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| type | `string` | | `"form"` 指定为 Form 渲染器 | +| name | `string` | | 设置一个名字后,方便其他组件与其通信 | +| mode | `string` | `normal` | 表单展示方式,可以是:`normal`、`horizontal` 或者 `inline` | +| horizontal | `Object` | `{"left":"col-sm-2", "right":"col-sm-10", "offset":"col-sm-offset-2"}` | 当 mode 为 `horizontal` 时有用,用来控制 label | +| title | `string` | `"表单"` | Form 的标题 | +| submitText | `String` | `"提交"` | 默认的提交按钮名称,如果设置成空,则可以把默认按钮去掉。 | +| className | `string` | | 外层 Dom 的类名 | +| controls | Array<[表单项](./formitem)> | | Form 表单项集合 | +| actions | Array<[行为按钮](../action)> | | Form 提交按钮,成员为 Action | +| messages | `Object` | | 消息提示覆写,默认消息读取的是 API 返回的消息,但是在此可以覆写它。 | +| messages.fetchSuccess | `string` | | 获取成功时提示 | +| messages.fetchFailed | `string` | | 获取失败时提示 | +| messages.saveSuccess | `string` | | 保存成功时提示 | +| messages.saveFailed | `string` | | 保存失败时提示 | +| wrapWithPanel | `boolean` | `true` | 是否让 Form 用 panel 包起来,设置为 false 后,actions 将无效。 | +| panelClassName | `string` | | 外层 panel 的类名 | +| api | [API](../../types/api) | | Form 用来保存数据的 api。 | +| initApi | [API](../../types/api) | | Form 用来获取初始数据的 api。 | +| rules | Array<{rule:string;message:string}> | | 表单组合校验规则 | +| interval | `number` | `3000` | 刷新时间(最低 3000) | +| silentPolling | `boolean` | `false` | 配置刷新时是否显示加载动画 | +| stopAutoRefreshWhen | `string` | `""` | 通过[表达式](./Types.md#表达式) 来配置停止刷新的条件 | +| initAsyncApi | [API](../../types/api) | | Form 用来获取初始数据的 api,与 initApi 不同的是,会一直轮询请求该接口,直到返回 finished 属性为 true 才 结束。 | +| initFetch | `boolean` | `true` | 设置了 initApi 或者 initAsyncApi 后,默认会开始就发请求,设置为 false 后就不会起始就请求接口 | +| initFetchOn | `string` | | 用表达式来配置 | +| initFinishedField | `string` | `finished` | 设置了 initAsyncApi 后,默认会从返回数据的 data.finished 来判断是否完成,也可以设置成其他的 xxx,就会从 data.xxx 中获取 | +| initCheckInterval | `number` | `3000` | 设置了 initAsyncApi 以后,默认拉取的时间间隔 | +| asyncApi | [API](../../types/api) | | 设置此属性后,表单提交发送保存接口后,还会继续轮询请求该接口,直到返回 `finished` 属性为 `true` 才 结束。 | +| checkInterval | `number` | 3000 | 轮询请求的时间间隔,默认为 3 秒。设置 `asyncApi` 才有效 | +| finishedField | `string` | `"finished"` | 如果决定结束的字段名不是 `finished` 请设置此属性,比如 `is_success` | +| submitOnChange | `boolean` | `false` | 表单修改即提交 | +| submitOnInit | `boolean` | `false` | 初始就提交一次 | +| resetAfterSubmit | `boolean` | `false` | 提交后是否重置表单 | +| primaryField | `string` | `"id"` | 设置主键 id, 当设置后,检测表单是否完成时(asyncApi),只会携带此数据。 | +| target | `string` | | 默认表单提交自己会通过发送 api 保存数据,但是也可以设定另外一个 form 的 name 值,或者另外一个 `CRUD` 模型的 name 值。 如果 target 目标是一个 `Form` ,则目标 `Form` 会重新触发 `initApi`,api 可以拿到当前 form 数据。如果目标是一个 `CRUD` 模型,则目标模型会重新触发搜索,参数为当前 Form 数据。当目标是 `window` 时,会把当前表单的数据附带到页面地址上。 | +| redirect | `string` | | 设置此属性后,Form 保存成功后,自动跳转到指定页面。支持相对地址,和绝对地址(相对于组内的)。 | +| reload | `string` | | 操作完后刷新目标对象。请填写目标组件设置的 name 值,如果填写为 `window` 则让当前页面整体刷新。 | +| autoFocus | `boolean` | `false` | 是否自动聚焦。 | +| canAccessSuperData | `boolean` | `true` | 指定是否可以自动获取上层的数据并映射到表单项上 | +| persistData | `boolean` | `true` | 指定表单是否开启本地缓存 | +| clearPersistDataAfterSubmit | `boolean` | `true` | 指定表单提交成功后是否清除本地缓存 | +| trimValues | `boolean` | `false` | trim 当前表单项的每一个值 | +| promptPageLeave | `boolean` | `false` | form 还没保存,即将离开页面前是否弹框确认。 | diff --git a/src/renderers/Form/index.tsx b/src/renderers/Form/index.tsx index 470afdf35..e9e569d3e 100644 --- a/src/renderers/Form/index.tsx +++ b/src/renderers/Form/index.tsx @@ -274,6 +274,14 @@ export interface FormSchema extends BaseSchema { * 具体的提示信息,选填。 */ promptPageLeaveMessage?: string; + + /** + * 组合校验规则,选填 + */ + rules: Array<{ + rule: string; + message: string; + }>; } export type FormGroup = FormSchema & { @@ -307,6 +315,10 @@ export interface FormProps extends RendererProps, Omit { saveFailed?: string; validateFailed?: string; }; + rules: Array<{ + rule: string; + message: string; + }>; lazyChange?: boolean; // 表单项的 formLazyChange?: boolean; // 表单的 } @@ -373,6 +385,7 @@ export default class Form extends React.Component { } = {}; asyncCancel: () => void; disposeOnValidate: () => void; + disposeRulesValidate: () => void; shouldLoadInitApi: boolean = false; timer: NodeJS.Timeout; mounted: boolean; @@ -441,7 +454,8 @@ export default class Form extends React.Component { messages: {fetchSuccess, fetchFailed}, onValidate, promptPageLeave, - env + env, + rules } = this.props; this.mounted = true; @@ -461,23 +475,38 @@ export default class Form extends React.Component { return; } + // 在setError之前,提前把残留的error信息清除掉,否则每次onValidate后都会一直把报错 append 上去 + items.forEach(item => item.clearError()); + if (msg) { msg = Array.isArray(msg) ? msg : [msg]; items.forEach(item => item.addError(msg)); - } else { - items.forEach(item => item.clearError()); } delete result[key]; }); isEmpty(result) - ? store.clearRestErrors() - : store.setRestErrors(result); + ? store.clearRestError() + : store.setRestError(Object.keys(result).map(key => result[key])); } }); } + if (Array.isArray(rules) && rules.length) { + this.disposeRulesValidate = this.addHook(() => { + if (!store.valid) { + return; + } + + rules.forEach( + item => + !evalExpression(item.rule, store.data) && + store.addRestError(item.message) + ); + }); + } + if (isEffectiveApi(initApi, store.data, initFetch, initFetchOn)) { store .fetchInitData(initApi as any, store.data, { @@ -543,6 +572,7 @@ export default class Form extends React.Component { this.lazyHandleChange.cancel(); this.asyncCancel && this.asyncCancel(); this.disposeOnValidate && this.disposeOnValidate(); + this.disposeRulesValidate && this.disposeRulesValidate(); this.componentCache.dispose(); window.removeEventListener('beforeunload', this.beforePageUnload); this.unBlockRouting?.(); @@ -767,7 +797,7 @@ export default class Form extends React.Component { onChange && onChange(store.data, difference(store.data, store.pristine), this.props); - store.clearRestErrors(); + store.clearRestError(); (submit || submitOnChange) && this.handleAction( @@ -1341,6 +1371,8 @@ export default class Form extends React.Component { render } = this.props; + const {restError} = store; + const WrapperComponent = this.props.wrapperComponent || (/(?:\/|^)form\//.test($path as string) ? 'div' : 'form'); @@ -1365,11 +1397,11 @@ export default class Form extends React.Component { controls })} - {/* 显示接口返回的 errors 中没有映射上的 */} - {store.restErrors ? ( -
    - {Object.keys(store.restErrors).map(key => ( -
  • {store.restErrors[key]}
  • + {/* 显示没有映射上的 errors */} + {restError && restError.length ? ( +
      + {restError.map((item, idx) => ( +
    • {item}
    • ))}
    ) : null} diff --git a/src/store/form.ts b/src/store/form.ts index d65fe9088..ae195b64a 100644 --- a/src/store/form.ts +++ b/src/store/form.ts @@ -41,7 +41,7 @@ export const FormStore = ServiceStore.named('FormStore') itemsRef: types.optional(types.array(types.string), []), canAccessSuperData: true, persistData: false, - restErrors: types.frozen() // 没有映射到表达项上的 errors + restError: types.optional(types.array(types.string), []) // 没有映射到表达项上的 errors }) .views(self => { function getItems() { @@ -94,7 +94,10 @@ export const FormStore = ServiceStore.named('FormStore') }, get valid() { - return getItems().every(item => item.valid); + return ( + getItems().every(item => item.valid) && + (!self.restError || !self.restError.length) + ); }, get isPristine() { @@ -226,12 +229,19 @@ export const FormStore = ServiceStore.named('FormStore') } ); - function setRestErrors(errors: any) { - self.restErrors = errors; + function setRestError(errors: string[]) { + self.restError.replace(errors); } - function clearRestErrors() { - setRestErrors(null); + function addRestError(msg: string | Array) { + const msgs: Array = Array.isArray(msg) ? msg : [msg]; + msgs.forEach(msg => { + self.restError.push(msg); + }); + } + + function clearRestError() { + setRestError([]); } const saveRemote: ( @@ -243,7 +253,7 @@ export const FormStore = ServiceStore.named('FormStore') data: object, options: fetchOptions = {} ) { - clearRestErrors(); + clearRestError(); try { options = { @@ -308,7 +318,8 @@ export const FormStore = ServiceStore.named('FormStore') }); // 没有映射上的error信息加在msg后显示出来 - !isEmpty(errors) && setRestErrors(errors); + !isEmpty(errors) && + setRestError(Object.keys(errors).map(key => errors[key])); self.updateMessage( json.msg ?? @@ -413,7 +424,7 @@ export const FormStore = ServiceStore.named('FormStore') try { let valid = yield validate(hooks); - if (!valid || self.restErrors) { + if (!valid) { const msg = failedMessage ?? self.__('Form.validateFailed'); msg && getEnv(self).notify('error', msg); throw new Error(self.__('Form.validateFailed')); @@ -581,8 +592,9 @@ export const FormStore = ServiceStore.named('FormStore') onChildStoreDispose, updateSavedData, getItemsByPath, - setRestErrors, - clearRestErrors, + setRestError, + addRestError, + clearRestError, beforeDestroy() { syncOptions.cancel(); setPersistData.cancel();