feat:formItem增加autoUpdate功能 (#3845)

Co-authored-by: dqc <qianchuan.deng@gmail.com>
This commit is contained in:
qianchuan 2022-04-02 19:54:38 +08:00 committed by GitHub
parent f1899524b7
commit f507e88496
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 291 additions and 69 deletions

View File

@ -1001,27 +1001,96 @@ Table 类型的表单项,要实现服务端校验,可以使用 `路径key`
- `status`: 返回 `0` 表示校验成功,`422` 表示校验失败; - `status`: 返回 `0` 表示校验成功,`422` 表示校验失败;
- `errors`: 返回 `status``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` | | 指定表单项类型 | | type | `string` | | 指定表单项类型 |
| className | `string` | | 表单最外层类名 | | className | `string` | | 表单最外层类名 |
| inputClassName | `string` | | 表单控制器类名 | | inputClassName | `string` | | 表单控制器类名 |
| labelClassName | `string` | | label 的类名 | | labelClassName | `string` | | label 的类名 |
| name | `string` | | 字段名,指定该表单项提交时的 key | | name | `string` | | 字段名,指定该表单项提交时的 key |
| value | `string` | | 表单默认值 | | value | `string` | | 表单默认值 |
| label | [模板](../../../docs/concepts/template) 或 `false` | | 表单项标签 | | label | [模板](../../../docs/concepts/template) 或 `false` | | 表单项标签 |
| labelRemark | [Remark](../remark) | | 表单项标签描述 | | labelRemark | [Remark](../remark) | | 表单项标签描述 |
| description | [模板](../../../docs/concepts/template) | | 表单项描述 | | description | [模板](../../../docs/concepts/template) | | 表单项描述 |
| placeholder | `string` | | 表单项描述 | | placeholder | `string` | | 表单项描述 |
| inline | `boolean` | | 是否为 内联 模式 | | inline | `boolean` | | 是否为 内联 模式 |
| submitOnChange | `boolean` | | 是否该表单项值发生变化时就提交当前表单。 | | submitOnChange | `boolean` | | 是否该表单项值发生变化时就提交当前表单。 |
| disabled | `boolean` | | 当前表单项是否是禁用状态 | | disabled | `boolean` | | 当前表单项是否是禁用状态 |
| disabledOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | | disabledOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 |
| visible | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | | visible | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 |
| visibleOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 | | visibleOn | [表达式](../../../docs/concepts/expression) | | 当前表单项是否禁用的条件 |
| required | `boolean` | | 是否为必填。 | | required | `boolean` | | 是否为必填。 |
| requiredOn | [表达式](../../../docs/concepts/expression) | | 过[表达式](../Types.md#表达式)来配置当前表单项是否为必填。 | | requiredOn | [表达式](../../../docs/concepts/expression) | | 过[表达式](../Types.md#表达式)来配置当前表单项是否为必填。 |
| validations | [表达式](../../../docs/concepts/expression) | | 表单项值格式验证,支持设置多个,多个规则用英文逗号隔开。 | | validations | [表达式](../../../docs/concepts/expression) | | 表单项值格式验证,支持设置多个,多个规则用英文逗号隔开。 |
| validateApi | [表达式](../../../docs/types/api) | | 表单校验接口 | | validateApi | [表达式](../../../docs/types/api) | | 表单校验接口 |
| autoUpdate | Object | | 自动填充配置 |
| autoUpdate.api | [api](../../types/api) | | 自动填充数据接口地址 |
| autoUpdate.mapping | Object | | 自动填充字段映射关系 |
| autoUpdate.showToast | `boolean` | | 是否展示数据格式错误提示,默认为 false |

View File

@ -63,8 +63,7 @@ export default {
label: '级联选项', label: '级联选项',
source: source:
'/api/mock2/options/chainedOptions?waitSeconds=1&parentId=$parentId&level=$level&maxLevel=4&waiSeconds=1', '/api/mock2/options/chainedOptions?waitSeconds=1&parentId=$parentId&level=$level&maxLevel=4&waiSeconds=1',
desc: desc: '无限级别, 只要 api 返回数据就能继续往下选择. 当没有下级时请返回 null.',
'无限级别, 只要 api 返回数据就能继续往下选择. 当没有下级时请返回 null.',
value: 'a,b' value: 'a,b'
}, },
{ {

View File

@ -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;

View File

@ -1,44 +1,45 @@
const db = [ const db = [
{ {
"label": "zhugeliang", label: 'zhugeliang',
"value": "1" value: '1'
}, },
{ {
"label": "zhongwuyan", label: 'zhongwuyan',
"value": "2" value: '2'
}, },
{ {
"label": "buzhihuowu", label: 'buzhihuowu',
"value": "3" value: '3'
}, },
{ {
"label": "zhongkui", label: 'zhongkui',
"value": "4" value: '4'
}, },
{ {
"label": "luna", label: 'luna',
"value": "5" value: '5'
}, },
{ {
"label": "wangzhaojun", label: 'wangzhaojun',
"value": "6" value: '6'
} }
]; ];
module.exports = function (req, res) {
const term = req.query.term || '';
module.exports = function(req, res) { res.json({
const term = req.query.term || ''; status: 0,
msg: '',
res.json({ data: term
status: 0, ? db.filter(function (item) {
msg: '', return term ? ~item.label.indexOf(term) : false;
data: term ? db.filter(function(item) { })
return term ? ~item.label.indexOf(term) : false; : db
}) : db });
}); };
}

View File

@ -127,6 +127,7 @@ register('de-DE', {
'File.upload': 'Hochladen', 'File.upload': 'Hochladen',
'File.uploadFailed': 'Zurückgegebene Daten der Upload-API sind leer', 'File.uploadFailed': 'Zurückgegebene Daten der Upload-API sind leer',
'File.uploading': 'Wird hochgeladen...', '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.loadOptionsFailed': 'Optionen wurden auf folgendem Grund nicht geladen: {{reason}}',
'Form.submit': 'Absenden', 'Form.submit': 'Absenden',
'Form.title': 'Formular', 'Form.title': 'Formular',

View File

@ -129,6 +129,7 @@ register('en-US', {
'File.upload': 'Upload', 'File.upload': 'Upload',
'File.uploadFailed': 'return data of udpload api is empty', 'File.uploadFailed': 'return data of udpload api is empty',
'File.uploading': 'Uploading', 'File.uploading': 'Uploading',
'FormItem.autoUpdateloadFaild': 'return data of autoUpdate api is error',
'Form.loadOptionsFailed': 'Failed to load options because: {{reason}}', 'Form.loadOptionsFailed': 'Failed to load options because: {{reason}}',
'Form.submit': 'Submit', 'Form.submit': 'Submit',
'Form.title': 'Form', 'Form.title': 'Form',

View File

@ -134,6 +134,7 @@ register('zh-CN', {
'File.upload': '文件上传', 'File.upload': '文件上传',
'File.uploadFailed': '接口返回错误,请仔细检查', 'File.uploadFailed': '接口返回错误,请仔细检查',
'File.uploading': '上传中...', 'File.uploading': '上传中...',
'FormItem.autoUpdateloadFaild': '接口返回错误,请仔细检查',
'Form.loadOptionsFailed': '加载选项失败,原因:{{reason}}', 'Form.loadOptionsFailed': '加载选项失败,原因:{{reason}}',
'Form.submit': '提交', 'Form.submit': '提交',
'Form.title': '表单', 'Form.title': '表单',

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import hoistNonReactStatic from 'hoist-non-react-statics'; import hoistNonReactStatic from 'hoist-non-react-statics';
import {IFormItemStore, IFormStore} from '../../store/form'; import {IFormItemStore, IFormStore} from '../../store/form';
import {reaction} from 'mobx'; import {IMapEntries, reaction} from 'mobx';
import { import {
RendererProps, RendererProps,
@ -14,7 +14,9 @@ import {
ucFirst, ucFirst,
getWidthRate, getWidthRate,
autobind, autobind,
isMobile isMobile,
createObject,
getVariable
} from '../../utils/helper'; } from '../../utils/helper';
import {observer} from 'mobx-react'; import {observer} from 'mobx-react';
import {FormHorizontal, FormSchema, FormSchemaHorizontal} from '.'; import {FormHorizontal, FormSchema, FormSchemaHorizontal} from '.';
@ -32,6 +34,10 @@ import {
import {HocStoreFactory} from '../../WithStore'; import {HocStoreFactory} from '../../WithStore';
import {wrapControl} from './wrapControl'; import {wrapControl} from './wrapControl';
import type {OnEventProps} from '../../utils/renderer-event'; 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; export type FormControlSchemaAlias = SchemaObject;
@ -396,7 +402,8 @@ export interface FormItemConfig extends FormItemBasicConfig {
} }
export class FormItemWrap extends React.Component<FormItemProps> { export class FormItemWrap extends React.Component<FormItemProps> {
reaction: any; reaction: Array<() => void> = [];
lastSearchTerm: any;
constructor(props: FormItemProps) { constructor(props: FormItemProps) {
super(props); super(props);
@ -404,15 +411,25 @@ export class FormItemWrap extends React.Component<FormItemProps> {
const {formItem: model} = props; const {formItem: model} = props;
if (model) { if (model) {
this.reaction = reaction( this.reaction.push(
() => `${model.errors.join('')}${model.isFocused}${model.dialogOpen}`, reaction(
() => this.forceUpdate() () => `${model.errors.join('')}${model.isFocused}${model.dialogOpen}`,
() => this.forceUpdate()
)
);
this.reaction.push(
reaction(
() => JSON.stringify(model.tmpValue),
() => this.syncAutoUpdate(model.tmpValue)
)
); );
} }
} }
componentWillUnmount() { componentWillUnmount() {
this.reaction && this.reaction(); this.reaction.forEach(fn => fn());
this.reaction = [];
this.syncAutoUpdate.cancel();
} }
@autobind @autobind
@ -429,6 +446,46 @@ export class FormItemWrap extends React.Component<FormItemProps> {
this.props.onBlur && this.props.onBlur(e); 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 @autobind
async handleOpenDialog(schema: Schema, data: any) { async handleOpenDialog(schema: Schema, data: any) {
const {formItem: model} = this.props; const {formItem: model} = this.props;
@ -482,6 +539,7 @@ export class FormItemWrap extends React.Component<FormItemProps> {
return renderControl({ return renderControl({
...rest, ...rest,
onOpenDialog: this.handleOpenDialog, onOpenDialog: this.handleOpenDialog,
type, type,
classnames: cx, classnames: cx,
formItem: model, formItem: model,

View File

@ -32,12 +32,13 @@ import {flattenTree} from '../utils/helper';
import {IRendererStore} from '.'; import {IRendererStore} from '.';
import {normalizeOptions, optionValueCompare} from '../components/Select'; import {normalizeOptions, optionValueCompare} from '../components/Select';
import find from 'lodash/find'; import find from 'lodash/find';
import isPlainObject from 'lodash/isPlainObject';
import {SimpleMap} from '../utils/SimpleMap'; import {SimpleMap} from '../utils/SimpleMap';
import memoize from 'lodash/memoize'; import memoize from 'lodash/memoize';
import {TranslateFn} from '../locale'; import {TranslateFn} from '../locale';
import {StoreNode} from './node'; import {StoreNode} from './node';
import {dataMapping} from '../utils/tpl-builtin';
import {getStoreById} from './manager'; import {getStoreById} from './manager';
import {toast} from '../components';
interface IOption { interface IOption {
value?: string | number | null; value?: string | number | null;
@ -215,6 +216,7 @@ export const FormItemStore = StoreNode.named('FormItemStore')
.actions(self => { .actions(self => {
const form = self.form as IFormStore; const form = self.form as IFormStore;
const dialogCallbacks = new SimpleMap<(result?: any) => void>(); const dialogCallbacks = new SimpleMap<(result?: any) => void>();
let loadAutoUpdateCancel: Function | null = null;
function config({ function config({
required, required,
@ -623,6 +625,39 @@ export const FormItemStore = StoreNode.named('FormItemStore')
return json; return json;
}); });
const loadAutoUpdateData: (
api: Api,
data?: object,
showToast?: boolean
) => Promise<Payload> = 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: ( const tryDeferLoadLeftOptions: (
option: any, option: any,
leftOptions: any, leftOptions: any,
@ -1166,7 +1201,8 @@ export const FormItemStore = StoreNode.named('FormItemStore')
changeTmpValue, changeTmpValue,
changeEmitedValue, changeEmitedValue,
addSubFormItem, addSubFormItem,
removeSubFormItem removeSubFormItem,
loadAutoUpdateData
}; };
}); });