From f507e8849604ccea90ba83ff2ff8b30ce016e213 Mon Sep 17 00:00:00 2001 From: qianchuan Date: Sat, 2 Apr 2022 19:54:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:formItem=E5=A2=9E=E5=8A=A0autoUpdate?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=20(#3845)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dqc --- docs/zh-CN/components/form/formitem.md | 113 ++++++++++++++++++++----- examples/components/Form/Remote.jsx | 3 +- mock/cfc/mock/form/autoUpdate.js | 56 ++++++++++++ mock/cfc/mock/options/autoComplete.js | 73 ++++++++-------- src/locale/de-DE.ts | 1 + src/locale/en-US.ts | 1 + src/locale/zh-CN.ts | 1 + src/renderers/Form/Item.tsx | 72 ++++++++++++++-- src/store/formItem.ts | 40 ++++++++- 9 files changed, 291 insertions(+), 69 deletions(-) create mode 100644 mock/cfc/mock/form/autoUpdate.js diff --git a/docs/zh-CN/components/form/formitem.md b/docs/zh-CN/components/form/formitem.md index 677c067dd..a27bf945f 100755 --- a/docs/zh-CN/components/form/formitem.md +++ b/docs/zh-CN/components/form/formitem.md @@ -1001,27 +1001,96 @@ Table 类型的表单项,要实现服务端校验,可以使用 `路径key` - `status`: 返回 `0` 表示校验成功,`422` 表示校验失败; - `errors`: 返回 `status` 为 `422` 时,显示的校验失败信息; +### 配置自动填充 + +通过配置 "autoUpdate" 来开启自动填充;其中 api 为自动填充数据源接口地址;mapping 为返回结果需要自动填充的 key value 映射关系; + +```schema:scope="body" +{ + "type": "form", + "body": [ + { + "type": "input-text", + "label": "浏览器", + "name": "browser", + "autoUpdate": { + api: "/api/mock2/form/autoUpdate?browser=$browser", + showToast: true, + mapping: { + browser: "browser", + version: "version", + platform1: "${browser}", + } + } + }, + { + "type": "input-text", + "label": "版本", + "name": "version" + }, + { + "type": "input-text", + "label": "平台", + "name": "platform1" + }, + ] +} +``` + +自动填充接口返回格式如下: +注意:amis 仅处理接口返回结果仅有一项的数据,默认自动填充相关字段 + +```json +{ + "status": 0, + "data": { + "rows": [ + { + "key": "value", + "key1": "value1" + } + ] + } +} +``` + +或者是 + +```json +{ + "status": 0, + "data": { + "key": "value", + "key1": "value1" + } +} +``` + ## 属性表 -| 属性名 | 类型 | 默认值 | 说明 | -| -------------- | -------------------------------------------------- | ------ | ---------------------------------------------------------- | -| type | `string` | | 指定表单项类型 | -| className | `string` | | 表单最外层类名 | -| inputClassName | `string` | | 表单控制器类名 | -| labelClassName | `string` | | label 的类名 | -| name | `string` | | 字段名,指定该表单项提交时的 key | -| value | `string` | | 表单默认值 | -| label | [模板](../../../docs/concepts/template) 或 `false` | | 表单项标签 | -| labelRemark | [Remark](../remark) | | 表单项标签描述 | -| description | [模板](../../../docs/concepts/template) | | 表单项描述 | -| placeholder | `string` | | 表单项描述 | -| inline | `boolean` | | 是否为 内联 模式 | -| submitOnChange | `boolean` | | 是否该表单项值发生变化时就提交当前表单。 | -| disabled | `boolean` | | 当前表单项是否是禁用状态 | -| disabledOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | -| visible | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | -| visibleOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | -| required | `boolean` | | 是否为必填。 | -| requiredOn | [表达式](../../../docs/concepts/expression) | | 过[表达式](../Types.md#表达式)来配置当前表单项是否为必填。 | -| validations | [表达式](../../../docs/concepts/expression) | | 表单项值格式验证,支持设置多个,多个规则用英文逗号隔开。 | -| validateApi | [表达式](../../../docs/types/api) | | 表单校验接口 | +| 属性名 | 类型 | 默认值 | 说明 | +| -------------------- | -------------------------------------------------- | ------ | ---------------------------------------------------------- | +| type | `string` | | 指定表单项类型 | +| className | `string` | | 表单最外层类名 | +| inputClassName | `string` | | 表单控制器类名 | +| labelClassName | `string` | | label 的类名 | +| name | `string` | | 字段名,指定该表单项提交时的 key | +| value | `string` | | 表单默认值 | +| label | [模板](../../../docs/concepts/template) 或 `false` | | 表单项标签 | +| labelRemark | [Remark](../remark) | | 表单项标签描述 | +| description | [模板](../../../docs/concepts/template) | | 表单项描述 | +| placeholder | `string` | | 表单项描述 | +| inline | `boolean` | | 是否为 内联 模式 | +| submitOnChange | `boolean` | | 是否该表单项值发生变化时就提交当前表单。 | +| disabled | `boolean` | | 当前表单项是否是禁用状态 | +| disabledOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | +| visible | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | +| visibleOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | +| required | `boolean` | | 是否为必填。 | +| requiredOn | [表达式](../../../docs/concepts/expression) | | 过[表达式](../Types.md#表达式)来配置当前表单项是否为必填。 | +| validations | [表达式](../../../docs/concepts/expression) | | 表单项值格式验证,支持设置多个,多个规则用英文逗号隔开。 | +| validateApi | [表达式](../../../docs/types/api) | | 表单校验接口 | +| autoUpdate | Object | | 自动填充配置 | +| autoUpdate.api | [api](../../types/api) | | 自动填充数据接口地址 | +| autoUpdate.mapping | Object | | 自动填充字段映射关系 | +| autoUpdate.showToast | `boolean` | | 是否展示数据格式错误提示,默认为 false | diff --git a/examples/components/Form/Remote.jsx b/examples/components/Form/Remote.jsx index b075ce6ee..540dae672 100644 --- a/examples/components/Form/Remote.jsx +++ b/examples/components/Form/Remote.jsx @@ -63,8 +63,7 @@ export default { label: '级联选项', source: '/api/mock2/options/chainedOptions?waitSeconds=1&parentId=$parentId&level=$level&maxLevel=4&waiSeconds=1', - desc: - '无限级别, 只要 api 返回数据就能继续往下选择. 当没有下级时请返回 null.', + desc: '无限级别, 只要 api 返回数据就能继续往下选择. 当没有下级时请返回 null.', value: 'a,b' }, { diff --git a/mock/cfc/mock/form/autoUpdate.js b/mock/cfc/mock/form/autoUpdate.js new file mode 100644 index 000000000..0a0b7796a --- /dev/null +++ b/mock/cfc/mock/form/autoUpdate.js @@ -0,0 +1,56 @@ +const result = async (req, res) => { + const sleep = ms => { + return new Promise(resolve => setTimeout(resolve, ms)); + }; + const data = [ + { + browser: 'Chrome', + version: '1', + platform: 'linux', + Chrome: 'xxx1' + }, + { + browser: 'Chrome', + version: '2', + platform: 'windows', + Chrome: 'xxx2' + }, + { + browser: 'Firefox', + version: '3', + platform: 'linux', + Chrome: 'xxx3' + }, + { + browser: 'Firefox', + version: '4', + platform: 'windows', + Chrome: 'xxx4' + }, + { + browser: 'Opera', + version: '5', + platform: 'linux', + Chrome: 'xxx5' + }, + { + browser: 'Opera', + version: '6', + platform: 'windows', + Chrome: 'xxx6' + } + ]; + + await sleep(1000); + const browser = req.query.browser; + res.json({ + status: 0, + msg: '', + data: + data.filter(function (item) { + return browser ? ~item.browser.indexOf(browser) : false; + })[0] || data[0] + }); +}; + +module.exports = result; diff --git a/mock/cfc/mock/options/autoComplete.js b/mock/cfc/mock/options/autoComplete.js index 741381ec9..32ce89527 100755 --- a/mock/cfc/mock/options/autoComplete.js +++ b/mock/cfc/mock/options/autoComplete.js @@ -1,44 +1,45 @@ -const db = [ - { - "label": "zhugeliang", - "value": "1" - }, +const db = [ + { + label: 'zhugeliang', + value: '1' + }, - { - "label": "zhongwuyan", - "value": "2" - }, + { + label: 'zhongwuyan', + value: '2' + }, - { - "label": "buzhihuowu", - "value": "3" - }, + { + label: 'buzhihuowu', + value: '3' + }, - { - "label": "zhongkui", - "value": "4" - }, + { + label: 'zhongkui', + value: '4' + }, - { - "label": "luna", - "value": "5" - }, + { + label: 'luna', + value: '5' + }, - { - "label": "wangzhaojun", - "value": "6" - } + { + label: 'wangzhaojun', + value: '6' + } ]; +module.exports = function (req, res) { + const term = req.query.term || ''; -module.exports = function(req, res) { - const term = req.query.term || ''; - - res.json({ - status: 0, - msg: '', - data: term ? db.filter(function(item) { - return term ? ~item.label.indexOf(term) : false; - }) : db - }); -} \ No newline at end of file + res.json({ + status: 0, + msg: '', + data: term + ? db.filter(function (item) { + return term ? ~item.label.indexOf(term) : false; + }) + : db + }); +}; diff --git a/src/locale/de-DE.ts b/src/locale/de-DE.ts index 28c2396df..a38ea0c18 100644 --- a/src/locale/de-DE.ts +++ b/src/locale/de-DE.ts @@ -127,6 +127,7 @@ register('de-DE', { 'File.upload': 'Hochladen', 'File.uploadFailed': 'Zurückgegebene Daten der Upload-API sind leer', 'File.uploading': 'Wird hochgeladen...', + 'FormItem.autoUpdateloadFaild': 'Die Schnittstelle hat einen Fehler zurückgegeben, bitte sorgfältig prüfen', 'Form.loadOptionsFailed': 'Optionen wurden auf folgendem Grund nicht geladen: {{reason}}', 'Form.submit': 'Absenden', 'Form.title': 'Formular', diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 1a13994a2..49be77d95 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -129,6 +129,7 @@ register('en-US', { 'File.upload': 'Upload', 'File.uploadFailed': 'return data of udpload api is empty', 'File.uploading': 'Uploading', + 'FormItem.autoUpdateloadFaild': 'return data of autoUpdate api is error', 'Form.loadOptionsFailed': 'Failed to load options because: {{reason}}', 'Form.submit': 'Submit', 'Form.title': 'Form', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index 1c923026f..6e8f13319 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -134,6 +134,7 @@ register('zh-CN', { 'File.upload': '文件上传', 'File.uploadFailed': '接口返回错误,请仔细检查', 'File.uploading': '上传中...', + 'FormItem.autoUpdateloadFaild': '接口返回错误,请仔细检查', 'Form.loadOptionsFailed': '加载选项失败,原因:{{reason}}', 'Form.submit': '提交', 'Form.title': '表单', diff --git a/src/renderers/Form/Item.tsx b/src/renderers/Form/Item.tsx index 8df45184a..86b53e17e 100644 --- a/src/renderers/Form/Item.tsx +++ b/src/renderers/Form/Item.tsx @@ -1,7 +1,7 @@ import React from 'react'; import hoistNonReactStatic from 'hoist-non-react-statics'; import {IFormItemStore, IFormStore} from '../../store/form'; -import {reaction} from 'mobx'; +import {IMapEntries, reaction} from 'mobx'; import { RendererProps, @@ -14,7 +14,9 @@ import { ucFirst, getWidthRate, autobind, - isMobile + isMobile, + createObject, + getVariable } from '../../utils/helper'; import {observer} from 'mobx-react'; import {FormHorizontal, FormSchema, FormSchemaHorizontal} from '.'; @@ -32,6 +34,10 @@ import { import {HocStoreFactory} from '../../WithStore'; import {wrapControl} from './wrapControl'; import type {OnEventProps} from '../../utils/renderer-event'; +import isEmpty from 'lodash/isEmpty'; +import debounce from 'lodash/debounce'; +import {isEffectiveApi} from '../../utils/api'; +import {dataMapping} from '../../utils/tpl-builtin'; export type FormControlSchemaAlias = SchemaObject; @@ -396,7 +402,8 @@ export interface FormItemConfig extends FormItemBasicConfig { } export class FormItemWrap extends React.Component { - reaction: any; + reaction: Array<() => void> = []; + lastSearchTerm: any; constructor(props: FormItemProps) { super(props); @@ -404,15 +411,25 @@ export class FormItemWrap extends React.Component { const {formItem: model} = props; if (model) { - this.reaction = reaction( - () => `${model.errors.join('')}${model.isFocused}${model.dialogOpen}`, - () => this.forceUpdate() + this.reaction.push( + reaction( + () => `${model.errors.join('')}${model.isFocused}${model.dialogOpen}`, + () => this.forceUpdate() + ) + ); + this.reaction.push( + reaction( + () => JSON.stringify(model.tmpValue), + () => this.syncAutoUpdate(model.tmpValue) + ) ); } } componentWillUnmount() { - this.reaction && this.reaction(); + this.reaction.forEach(fn => fn()); + this.reaction = []; + this.syncAutoUpdate.cancel(); } @autobind @@ -429,6 +446,46 @@ export class FormItemWrap extends React.Component { this.props.onBlur && this.props.onBlur(e); } + syncAutoUpdate = debounce( + (term: any) => { + (async (term: string) => { + const {autoUpdate, onBulkChange, formItem, data} = this.props; + if (!autoUpdate || (autoUpdate && !autoUpdate.mapping)) { + return; + } + + const {api, showToast} = autoUpdate; + let mapping = {...autoUpdate.mapping}; + const itemName = formItem?.name; + const ctx = createObject(data, { + [itemName || '']: term + }); + mapping = dataMapping(mapping, ctx); + if ( + !isEmpty(mapping) && + onBulkChange && + isEffectiveApi(api, ctx) && + this.lastSearchTerm !== term + ) { + const result = await formItem?.loadAutoUpdateData( + api, + ctx, + showToast + ); + if (!result) return; + mapping = dataMapping(autoUpdate.mapping, result); + this.lastSearchTerm = getVariable(mapping, itemName) ?? term; + onBulkChange(mapping); + } + })(term).catch(e => console.error(e)); + }, + 250, + { + trailing: true, + leading: false + } + ); + @autobind async handleOpenDialog(schema: Schema, data: any) { const {formItem: model} = this.props; @@ -482,6 +539,7 @@ export class FormItemWrap extends React.Component { return renderControl({ ...rest, onOpenDialog: this.handleOpenDialog, + type, classnames: cx, formItem: model, diff --git a/src/store/formItem.ts b/src/store/formItem.ts index 2f730be0a..0eea8aee7 100644 --- a/src/store/formItem.ts +++ b/src/store/formItem.ts @@ -32,12 +32,13 @@ import {flattenTree} from '../utils/helper'; import {IRendererStore} from '.'; import {normalizeOptions, optionValueCompare} from '../components/Select'; import find from 'lodash/find'; +import isPlainObject from 'lodash/isPlainObject'; import {SimpleMap} from '../utils/SimpleMap'; import memoize from 'lodash/memoize'; import {TranslateFn} from '../locale'; import {StoreNode} from './node'; -import {dataMapping} from '../utils/tpl-builtin'; import {getStoreById} from './manager'; +import {toast} from '../components'; interface IOption { value?: string | number | null; @@ -215,6 +216,7 @@ export const FormItemStore = StoreNode.named('FormItemStore') .actions(self => { const form = self.form as IFormStore; const dialogCallbacks = new SimpleMap<(result?: any) => void>(); + let loadAutoUpdateCancel: Function | null = null; function config({ required, @@ -623,6 +625,39 @@ export const FormItemStore = StoreNode.named('FormItemStore') return json; }); + const loadAutoUpdateData: ( + api: Api, + data?: object, + showToast?: boolean + ) => Promise = flow(function* getAutoUpdateData( + api: string, + data: object, + showToast: boolean = false + ) { + if (loadAutoUpdateCancel) { + loadAutoUpdateCancel(); + loadAutoUpdateCancel = null; + } + + const json: Payload = yield getEnv(self).fetcher(api, data, { + cancelExecutor: (executor: Function) => + (loadAutoUpdateCancel = executor) + }); + loadAutoUpdateCancel = null; + if (!json) { + return; + } + const result = json.data?.items || json.data?.rows; + // 只处理仅有一个结果的数据 + if (result?.length === 1) { + return result[0]; + } else if (isPlainObject(json.data)) { + return json.data; + } + showToast && toast.info(self.__('FormItem.autoUpdateloadFaild')); + return; + }); + const tryDeferLoadLeftOptions: ( option: any, leftOptions: any, @@ -1166,7 +1201,8 @@ export const FormItemStore = StoreNode.named('FormItemStore') changeTmpValue, changeEmitedValue, addSubFormItem, - removeSubFormItem + removeSubFormItem, + loadAutoUpdateData }; });