feat: InputGroup报错展示优化 (#5803)

This commit is contained in:
RUNZE LU 2023-02-27 20:48:07 +08:00 committed by GitHub
parent 329518f88a
commit 6f83be8a5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 344 additions and 32 deletions

View File

@ -96,12 +96,56 @@ order: 28
input-group 配置校验方法较为特殊,需要配置下面步骤:
1. input-group 上配置任意`name`值
1. input-group 上配置任意`name`值 (必填, 否则表单内存在多个输入组合时无法定位)
2. input-group 的 body 内配置的表单项上配置校验规则
3. 如果 input-group 的子元素配置了`label`, 则会在校验失败时作为标识符展示, 否则仅使用索引值作为标识符
4. 单个子元素多条校验信息会使用`; `分隔
5. 可以使用`"errorMode": "full" | "partial"`设置错误提示风格, `full`整体飘红, `partial`仅错误元素飘红, 默认为`"full"`
> 细粒度错误提示需`2.7.1`及以上版本
```schema: scope="body"
{
"type": "form",
"debug": true,
"api": "/api/mock2/form/saveForm",
"body": [
{
"type": "input-group",
"name": "input-group",
"label": "输入组合校验",
"body": [
{
"type": "input-text",
"placeholder": "请输入长度不超过6的数字类型",
"name": "group-input1",
"label": "子元素一",
"validations": {
"isNumeric": true,
"maxLength": 6
},
},
{
"type": "input-text",
"placeholder": "请输入长度不少于5的文本",
"name": "group-input2",
"required": true,
"validations": {
"minLength": 5
}
}
]
}
]
}
```
## 属性表
| 属性名 | 类型 | 默认值 | 说明 |
| --------- | --------------------------- | ------ | ---------- |
| className | `string` | | CSS 类名 |
| body | Array<[表单项](./formitem)> | | 表单项集合 |
| 属性名 | 类型 | 默认值 | 说明 | 版本 |
| ---------------- | ----------------------------------------- | -------- | ----------------------------------------------------- | ------- |
| className | `string` | | CSS 类名 | |
| body | Array<[表单项](./formitem)> | | 表单项集合 | |
| validationConfig | `Record<'errorMode' \| 'delimiter', any>` | - | 校验相关配置, 具体配置属性如下 | `2.7.3` |
| +errorMode | `"full" \| "partial"` | `"full"` | 错误提示风格, `full`整体飘红, `partial`仅错误元素飘红 | `2.7.3` |
| +delimiter | `string` | `"; "` | 单个子元素多条校验信息的分隔符 | `2.7.3` |

View File

@ -876,6 +876,7 @@ export class FormItemWrap extends React.Component<FormItemProps> {
} = this.props;
const mobileUI = useMobileUI && isMobile();
if (renderControl) {
const controlSize = size || defaultSize;
return renderControl({

View File

@ -118,6 +118,7 @@ export function wrapControl<
store,
onChange,
data,
inputGroupControl,
$schema: {
name,
id,
@ -201,7 +202,8 @@ export function wrapControl<
minLength,
maxLength,
validateOnChange,
label
label,
inputGroupControl
});
// issue 这个逻辑应该在 combo 里面自己实现。
@ -335,7 +337,8 @@ export function wrapControl<
validateApi: props.$schema.validateApi,
minLength: props.$schema.minLength,
maxLength: props.$schema.maxLength,
label: props.$schema.label
label: props.$schema.label,
inputGroupControl: props?.inputGroupControl
});
}

View File

@ -89,6 +89,28 @@ export const FormStore = ServiceStore.named('FormStore')
return formItems;
},
/** 获取InputGroup的子元素 */
get inputGroupItems() {
const formItems: Record<string, IFormItemStore[]> = {};
const children = self.children.concat();
while (children.length) {
const current = children.shift();
if (current.inputGroupControl && current.inputGroupControl?.name) {
const controlName = current.inputGroupControl?.name as string;
if (formItems.hasOwnProperty(controlName)) {
formItems[controlName].push(current);
} else {
formItems[controlName] = [current];
}
}
}
return formItems;
},
get errors() {
let errors: {
[propName: string]: Array<string>;

View File

@ -15,6 +15,7 @@ import {evalExpression} from '../utils/tpl';
import {buildApi, isEffectiveApi} from '../utils/api';
import findIndex from 'lodash/findIndex';
import {
isObject,
isArrayChildrenModified,
createObject,
isObjectShallowModified,
@ -87,7 +88,9 @@ export const FormItemStore = StoreNode.named('FormItemStore')
dialogOpen: false,
dialogData: types.frozen(),
resetValue: types.optional(types.frozen(), ''),
validateOnChange: false
validateOnChange: false,
/** 当前表单项所属的InputGroup父元素, 用于收集InputGroup的子元素 */
inputGroupControl: types.optional(types.frozen(), {})
})
.views(self => {
function getForm(): any {
@ -236,7 +239,8 @@ export const FormItemStore = StoreNode.named('FormItemStore')
maxLength,
minLength,
validateOnChange,
label
label,
inputGroupControl
}: {
required?: boolean;
unique?: boolean;
@ -260,6 +264,11 @@ export const FormItemStore = StoreNode.named('FormItemStore')
maxLength?: number;
validateOnChange?: boolean;
label?: string;
inputGroupControl?: {
name: string;
path: string;
[propsName: string]: any;
};
}) {
if (typeof rules === 'string') {
rules = str2rules(rules);
@ -289,6 +298,9 @@ export const FormItemStore = StoreNode.named('FormItemStore')
(self.validateOnChange = !!validateOnChange);
typeof label === 'string' && (self.label = label);
self.isValueSchemaExp = !!isValueSchemaExp;
isObject(inputGroupControl) &&
inputGroupControl?.name != null &&
(self.inputGroupControl = inputGroupControl);
rules = {
...rules,

View File

@ -156,3 +156,14 @@
.#{$ns}InputGroup:not(.is-inline) {
display: flex;
}
.#{$ns}InputGroup-validation--full.is-error > .#{$ns}Form-control {
border-color: var(--Form-input-onError-borderColor);
transition: all var(--animation-duration);
}
.#{$ns}InputGroup-validation--partial.is-error > .#{$ns}Form-control.is-error {
border-color: var(--Form-input-onError-borderColor);
background: var(--Form-input-onError-bg);
transition: all var(--animation-duration);
}

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Renderer:fieldSet 1`] = `
exports[`Renderer:InputGroup 1`] = `
<div>
<div
class="cxd-Panel cxd-Panel--default cxd-Panel--form"
@ -53,7 +53,7 @@ exports[`Renderer:fieldSet 1`] = `
class="cxd-Form-value"
>
<div
class="cxd-InputGroup cxd-Form-control"
class="cxd-InputGroup cxd-InputGroup-validation--full cxd-Form-control"
>
<div
class="cxd-TextControl-input cxd-Form-control"

View File

@ -1,10 +1,28 @@
import React = require('react');
import {render, fireEvent} from '@testing-library/react';
import '../../../src';
import {render as amisRender} from '../../../src';
import {makeEnv} from '../../helper';
/**
* 组件名称: InputGroup
* :
* 1. 使
* 2.
*
*/
test('Renderer:fieldSet', async () => {
import {
render,
cleanup,
fireEvent,
screen,
waitFor
} from '@testing-library/react';
import '../../../src';
import {render as amisRender, clearStoresCache} from '../../../src';
import {makeEnv, wait} from '../../helper';
afterEach(() => {
cleanup();
clearStoresCache();
});
test('Renderer:InputGroup', async () => {
const {container} = render(
amisRender(
{
@ -50,3 +68,110 @@ test('Renderer:fieldSet', async () => {
expect(container).toMatchSnapshot();
});
const setup = (schema = {}, props = {}, env = {}) => {
const onSubmit = jest.fn();
const submitBtnText = 'Submit';
return render(
amisRender(
{
type: 'form',
api: '/api/mock2/form/saveForm',
submitText: submitBtnText,
body: [
{
type: 'input-group',
name: 'input-group',
label: '输入组合校验',
body: [
{
type: 'input-text',
name: 'child1',
label: 'child1',
validations: {
isNumeric: true,
maxLength: 3
}
},
{
type: 'input-text',
name: 'child2',
validations: {
minLength: 5
}
}
],
...schema
}
]
},
{onSubmit, ...props},
makeEnv({...env})
)
);
};
describe('InputGroup with validationConfig', () => {
test('InputGroup with validationConfig: errorMode', async () => {
const {container} = setup({validationConfig: {errorMode: 'partial'}});
const child1 = container.querySelector(
'input[name=child1]'
) as HTMLInputElement;
const child2 = container.querySelector(
'input[name=child2]'
) as HTMLInputElement;
fireEvent.change(child1, {target: {value: 'amis'}});
await wait(300);
const submitBtn = screen.getByRole('button', {name: 'Submit'});
fireEvent.click(submitBtn);
await wait(500);
expect(
container.querySelector('*[class*="InputGroup-validation--partial"]')
).toBeInTheDocument();
const errorDom = container.querySelector('*[class*="Form-feedback"]');
expect(errorDom?.childElementCount).toStrictEqual(1);
fireEvent.change(child2, {target: {value: 'amis'}});
await wait(300);
fireEvent.click(submitBtn);
await wait(500);
expect(errorDom?.childElementCount).toStrictEqual(2);
});
test('InputGroup with validationConfig: delimiter', async () => {
const delimiter = '@@';
const {container} = setup({validationConfig: {delimiter}});
const child1 = container.querySelector(
'input[name=child1]'
) as HTMLInputElement;
const child2 = container.querySelector(
'input[name=child2]'
) as HTMLInputElement;
fireEvent.change(child1, {target: {value: 'amis'}});
await wait(500);
const submitBtn = screen.getByRole('button', {name: 'Submit'});
fireEvent.click(submitBtn);
await wait(500);
screen.debug(container);
expect(
container.querySelector('*[class*="InputGroup-validation--full"]')
).toBeInTheDocument();
const errorDom = container.querySelector('*[class*="Form-feedback"]');
expect(errorDom?.childElementCount).toStrictEqual(1);
const child1ErrorText = errorDom?.childNodes[0]
? errorDom.childNodes[0].textContent
: '';
expect(child1ErrorText).toEqual(expect.stringMatching(delimiter));
});
});

View File

@ -1,19 +1,14 @@
import React from 'react';
import {
makeColumnClassBuild,
makeHorizontalDeeper,
isVisible,
isDisabled
getExprProperties,
FormItem,
FormControlProps,
IFormItemStore,
IFormStore,
anyChanged
} from 'amis-core';
import {getExprProperties} from 'amis-core';
import {FormItem, FormControlProps, FormBaseControl} from 'amis-core';
import {IFormItemStore, IFormStore} from 'amis-core';
import {
FormBaseControlSchema,
SchemaClassName,
SchemaCollection,
SchemaObject
} from '../../Schema';
import {FormBaseControlSchema, SchemaCollection} from '../../Schema';
/**
* InputGroup
@ -26,6 +21,21 @@ export interface InputGroupControlSchema extends FormBaseControlSchema {
* FormItem
*/
body: SchemaCollection;
/**
*
*/
validationConfig?: {
/**
* , full为整体飘红, highlight为仅错误项飘红, full
*/
errorMode?: 'full' | 'partial';
/**
*
*/
delimiter?: string;
};
}
export interface InputGroupProps extends FormControlProps {
@ -41,17 +51,90 @@ export class InputGroup extends React.Component<
InputGroupProps,
InputGroupState
> {
static defaultProps = {
validationConfig: {
errorMode: 'full',
delimiter: '; '
}
};
toDispose: Array<Function> = [];
constructor(props: InputGroupProps) {
super(props);
this.handleFocus = this.handleFocus.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.validateHook = this.validateHook.bind(this);
this.state = {
isFocused: false
};
}
componentDidMount() {
const {addHook, name} = this.props;
if (name && addHook) {
this.toDispose.push(addHook(this.validateHook, 'validate'));
}
}
componentDidUpdate(prevProps: Readonly<InputGroupProps>): void {
if (
anyChanged(
['errorCode', 'delimiter'],
prevProps?.validationConfig,
this.props?.validationConfig
)
) {
this.validateHook();
}
}
componentWillUnmount() {
this.toDispose.forEach(fn => fn());
this.toDispose = [];
}
getValidationConfig() {
const {validationConfig} = this.props;
return {
errorMode: validationConfig?.errorMode !== 'partial' ? 'full' : 'partial',
delimiter:
validationConfig?.delimiter &&
typeof validationConfig.delimiter === 'string'
? validationConfig.delimiter
: '; '
};
}
validateHook() {
const {formStore, formItem, name} = this.props;
const {delimiter} = this.getValidationConfig();
if (!name) {
return;
}
const chidren = formStore?.inputGroupItems?.[name];
const errorCollection = chidren
.map((item, index) => {
if (item.errors.length <= 0) {
return '';
}
/** 标识符格式: 索引值 + label */
const identifier = item.label
? `(${index + 1})${item.label}`
: `(${index + 1})`;
return `${identifier}: ${item.errors.join(delimiter)}`;
})
.filter(Boolean);
formItem && formItem.setError(errorCollection);
}
handleFocus() {
this.setState({
isFocused: true
@ -109,6 +192,7 @@ export class InputGroup extends React.Component<
static: isStatic,
disabled
} = this.props;
const {errorMode} = this.getValidationConfig();
formMode = mode || formMode;
let inputs: Array<any> = Array.isArray(controls) ? controls : body;
@ -136,9 +220,14 @@ export class InputGroup extends React.Component<
: undefined);
return (
<div
className={cx(`InputGroup`, className, {
'is-focused': this.state.isFocused
})}
className={cx(
`InputGroup`,
`InputGroup-validation--${errorMode}`,
className,
{
'is-focused': this.state.isFocused
}
)}
>
{inputs.map((control, index) => {
const isAddOn = ~[
@ -154,6 +243,11 @@ export class InputGroup extends React.Component<
formHorizontal: horizontalDeeper,
formMode: 'normal',
inputOnly: true,
inputGroupControl: {
name: this.props.name,
path: this.props.$path,
schema: this.props.$schema
},
key: index,
static: isStatic,
disabled,