feat: form validator (#569)

* feat: form validator

* fix: max can equal min

* feat: adjust input validation rule

* feat: improve field validation

* feat: optimize field validation

* feat: improve field validation

* fix: percent validation

Co-authored-by: chenos <chenlinxh@gmail.com>
This commit is contained in:
金昶 2022-07-11 17:23:19 +08:00 committed by GitHub
parent 68d35cf597
commit 2282ec1a2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 651 additions and 9 deletions

View File

@ -5,6 +5,7 @@ import React, { useEffect } from 'react';
import { useCompile, useComponent, useFormBlockContext } from '..';
import { CollectionFieldProvider } from './CollectionFieldProvider';
import { useCollectionField } from './hooks';
import { concat } from 'lodash';
// TODO: 初步适配
const InternalField: React.FC = (props) => {
@ -38,8 +39,9 @@ const InternalField: React.FC = (props) => {
setFieldProps('title', uiSchema.title);
setFieldProps('description', uiSchema.description);
setFieldProps('initialValue', uiSchema.default);
if (!field.validator && uiSchema['x-validator']) {
field.validator = uiSchema['x-validator'];
if (!field.validator && (uiSchema['x-validator'] || fieldSchema['x-validator'])) {
const concatSchema = concat([], uiSchema['x-validator'] || [], fieldSchema['x-validator'] || [])
field.validator = concatSchema;
}
if (fieldSchema['x-disabled'] === true) {
field.disabled = true;

View File

@ -1,6 +1,7 @@
import { ISchema } from '@formily/react';
import { defaultProps, operators } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
export const input: IField = {
name: 'input',
@ -29,4 +30,110 @@ export const input: IField = {
schema['x-component-props']['ellipsis'] = true;
}
},
validateSchema(fieldSchema) {
return {
max: {
type: 'number',
title: '{{ t("Max length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': `{{(field) => {
const targetValue = field.query('.min').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Max length must greater than min length')}' : ''
}}}`,
},
min: {
type: 'number',
title: '{{ t("Min length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': {
dependencies: ['.max'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Min length must less than max length')}' : ''}}`,
},
},
},
},
len: {
type: 'number',
title: '{{ t("Length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
},
format: {
type: 'string',
title: '{{ t("Format") }}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
allowClear: true,
},
enum: [{
label: '{{ t("url") }}',
value: 'url',
}, {
label: '{{ t("email") }}',
value: 'email',
}, {
label: '{{ t("ipv6") }}',
value: 'ipv6',
}, {
label: '{{ t("ipv4") }}',
value: 'ipv4',
}, {
label: '{{ t("number") }}',
value: 'number',
}, {
label: '{{ t("integer") }}',
value: 'integer',
}, {
label: '{{ t("idcard") }}',
value: 'idcard',
}, {
label: '{{ t("qq") }}',
value: 'qq',
}, {
label: '{{ t("phone") }}',
value: 'phone',
}, {
label: '{{ t("money") }}',
value: 'money',
}, {
label: '{{ t("zh") }}',
value: 'zh',
}, {
label: '{{ t("date") }}',
value: 'date',
}, {
label: '{{ t("zip") }}',
value: 'zip',
}]
},
pattern: {
type: 'string',
title: '{{ t("Regular expression") }}',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
prefix: '/',
suffix: '/',
}
},
};
}
};

View File

@ -1,5 +1,13 @@
import { defaultProps, operators } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
import { registerValidateFormats } from '@formily/core';
import { ISchema } from '@formily/react';
registerValidateFormats({
odd: /^-?\d*[13579]$/,
even: /^-?\d*[02468]$/
});
export const integer: IField = {
name: 'integer',
@ -28,4 +36,65 @@ export const integer: IField = {
filterable: {
operators: operators.number,
},
validateSchema(fieldSchema) {
return {
maximum: {
type: 'number',
title: '{{ t("Maximum") }}',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': `{{(field) => {
const targetValue = field.query('.minimum').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Maximum must greater than minimum')}' : ''
}}}`,
},
minimum: {
type: 'number',
title: '{{ t("Minimum") }}',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': {
dependencies: ['.maximum'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Minimum must less than maximum')}' : ''}}`,
},
},
},
},
format: {
type: 'string',
title: '{{ t("Format") }}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
allowClear: true,
},
enum: [{
label: '{{ t("Odd") }}',
value: 'odd',
}, {
label: '{{ t("Even") }}',
value: 'even',
}]
},
pattern: {
type: 'string',
title: '{{ t("Regular expression") }}',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
prefix: '/',
suffix: '/',
}
},
};
}
};

View File

@ -1,6 +1,7 @@
import { ISchema } from '@formily/react';
import { defaultProps } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n'
export const markdown: IField = {
name: 'markdown',
@ -25,4 +26,41 @@ export const markdown: IField = {
schema['x-component-props']['ellipsis'] = true;
}
},
validateSchema(fieldSchema) {
return {
max: {
type: 'number',
title: '{{ t("Max length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': `{{(field) => {
const targetValue = field.query('.min').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Max length must greater than min length')}' : ''
}}}`,
},
min: {
type: 'number',
title: '{{ t("Min length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': {
dependencies: ['.max'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Min length must less than max length')}' : ''}}`,
},
},
},
},
};
}
};

View File

@ -1,5 +1,8 @@
import { registerValidateRules } from '@formily/core';
import { ISchema } from '@formily/react';
import { defaultProps, operators } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
export const number: IField = {
name: 'number',
@ -42,4 +45,62 @@ export const number: IField = {
filterable: {
operators: operators.number,
},
validateSchema(fieldSchema) {
return {
maximum: {
type: 'number',
title: '{{ t("Maximum") }}',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-reactions': `{{(field) => {
const targetValue = field.query('.minimum').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Maximum must greater than minimum')}' : ''
}}}`,
},
minimum: {
type: 'number',
title: '{{ t("Minimum") }}',
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-reactions': {
dependencies: ['.maximum'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Minimum must less than maximum')}' : ''}}`,
},
},
},
},
format: {
type: 'string',
title: '{{ t("Format") }}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
allowClear: true,
},
enum: [{
label: '{{ t("Integer") }}',
value: 'integer',
}, {
label: '{{ t("Odd") }}',
value: 'odd',
}, {
label: '{{ t("Even") }}',
value: 'even',
}]
},
pattern: {
type: 'string',
title: '{{ t("Regular expression") }}',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
prefix: '/',
suffix: '/',
}
},
};
}
};

View File

@ -1,5 +1,7 @@
import { ISchema } from '@formily/react';
import { defaultProps } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
export const password: IField = {
name: 'password',
@ -19,4 +21,41 @@ export const password: IField = {
properties: {
...defaultProps,
},
validateSchema(fieldSchema) {
return {
max: {
type: 'number',
title: '{{ t("Max length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': `{{(field) => {
const targetValue = field.query('.min').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Max length must greater than min length')}' : ''
}}}`,
},
min: {
type: 'number',
title: '{{ t("Min length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': {
dependencies: ['.max'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Min length must less than max length')}' : ''}}`,
},
},
},
},
};
}
};

View File

@ -1,5 +1,51 @@
import { ISchema } from '@formily/react';
import { defaultProps, operators } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
import { registerValidateFormats, registerValidateRules, registerValidateLocale } from '@formily/core';
registerValidateRules({
percentMode(value, rule) {
const { maxValue, minValue } = rule;
if (maxValue) {
if (value > maxValue) {
return {
type: 'error',
message: `${i18n.t('The field value cannot be greater than ')}${maxValue * 100}%`,
}
}
}
if (minValue) {
if (value < minValue) {
return {
type: 'error',
message: `${i18n.t('The field value cannot be less than ')}${minValue * 100}%`,
}
}
}
return true;
},
percentFormats(value, rule) {
const { percentFormat } = rule;
if (value && percentFormat === 'Integer' && /^-?[1-9]\d*$/.test((value * 100).toString()) === false) {
return {
type: 'error',
message: `${i18n.t('The field value is not an integer number')}`,
}
}
return true;
}
})
// registerValidateFormats({
// percentInteger: /^(\d+)(.\d{0,2})?$/,
// });
export const percent: IField = {
name: 'percent',
@ -43,4 +89,62 @@ export const percent: IField = {
filterable: {
operators: operators.number,
},
validateSchema(fieldSchema) {
return {
maxValue: {
type: 'number',
title: '{{ t("Maximum") }}',
'x-decorator': 'FormItem',
'x-component': 'Percent',
'x-component-props': {
addonAfter: '%',
},
'x-reactions': `{{(field) => {
const targetValue = field.query('.minimum').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Maximum must greater than minimum')}' : ''
}}}`,
},
minValue: {
type: 'number',
title: '{{ t("Minimum") }}',
'x-decorator': 'FormItem',
'x-component': 'Percent',
'x-component-props': {
addonAfter: '%',
},
'x-reactions': {
dependencies: ['.maximum'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Minimum must less than maximum')}' : ''}}`,
},
},
},
},
percentFormat: {
type: 'string',
title: '{{ t("Format") }}',
'x-decorator': 'FormItem',
'x-component': 'Select',
'x-component-props': {
allowClear: true,
},
enum: [{
label: '{{ t("Integer") }}',
value: 'Integer',
}]
},
pattern: {
type: 'string',
title: '{{ t("Regular expression") }}',
'x-decorator': 'FormItem',
'x-component': 'Input',
'x-component-props': {
prefix: '/',
suffix: '/',
}
},
};
}
};

View File

@ -1,6 +1,7 @@
import type { ISchema } from '@formily/react';
import { defaultProps } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
export const richText: IField = {
name: 'richText',
@ -26,4 +27,41 @@ export const richText: IField = {
schema['x-component-props']['ellipsis'] = true;
}
},
validateSchema(fieldSchema, formItemStyle) {
return {
max: {
type: 'number',
title: '{{ t("Max length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': `{{(field) => {
const targetValue = field.query('.min').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Max length must greater than min length')}' : ''
}}}`,
},
min: {
type: 'number',
title: '{{ t("Min length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': {
dependencies: ['.max'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Min length must less than max length')}' : ''}}`,
},
},
},
},
};
}
};

View File

@ -1,6 +1,7 @@
import { ISchema } from '@formily/react';
import { defaultProps } from './properties';
import { IField } from './types';
import { i18n } from '../../i18n';
export const textarea: IField = {
name: 'textarea',
@ -26,4 +27,41 @@ export const textarea: IField = {
schema['x-component-props']['ellipsis'] = true;
}
},
validateSchema(fieldSchema) {
return {
max: {
type: 'number',
title: '{{ t("Max length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': `{{(field) => {
const targetValue = field.query('.min').value();
field.selfErrors =
!!targetValue && !!field.value && targetValue > field.value ? '${i18n.t('Max length must greater than min length')}' : ''
}}}`,
},
min: {
type: 'number',
title: '{{ t("Min length") }}',
minimum: 0,
'x-decorator': 'FormItem',
'x-component': 'InputNumber',
'x-component-props': {
precision: 0
},
'x-reactions': {
dependencies: ['.max'],
fulfill: {
state: {
selfErrors: `{{!!$deps[0] && !!$self.value && $deps[0] < $self.value ? '${i18n.t('Min length must less than max length')}' : ''}}`,
},
},
},
},
};
}
};

View File

@ -549,4 +549,5 @@ export default {
"Field component": "Field component",
"Subtable": "Subtable",
"Subform": "Subform",
"Regular expression": "Pattern",
}

View File

@ -236,6 +236,25 @@ export default {
"Default is the ID field": "默认为 ID 字段",
"Set default sorting rules": "设置排序规则",
"Set validation rules": "设置验证规则",
"Max length": "最大长度",
"Min length": "最小长度",
"Maximum": "最大值",
"Minimum": "最小值",
"Max length must greater than min length": "最大长度必须大于最小长度",
"Min length must less than max length": "最小长度必须小于最大长度",
"Maximum must greater than minimum": "最大值必须大于最小值",
"Minimum must less than maximum": "最小值必须小于最大值",
"Validation rule": "验证规则",
"Add validation rule": "新增验证规则",
"Format": "格式",
"Regular expression": "正则表达式",
"Error message": "错误消息",
"Length": "长度",
"The field value cannot be greater than ": "数值不能大于",
"The field value cannot be less than ": "数值不能小于",
"The field value is not an integer number": "数字不是整数",
"is before": "早于",
"is after": "晚于",
"is on or after": "不早于",

View File

@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { FormItem as Item } from '@formily/antd';
import { ArrayCollapse, FormItem as Item, FormLayout } from '@formily/antd';
import { Field } from '@formily/core';
import { ISchema, useField, useFieldSchema } from '@formily/react';
import { uid } from '@formily/shared';
@ -11,6 +11,7 @@ import { useCollection, useCollectionManager } from '../../../collection-manager
import { GeneralSchemaDesigner, SchemaSettings } from '../../../schema-settings';
import { BlockItem } from '../block-item';
import { HTMLEncode } from '../input/shared';
import * as _ from 'lodash';
const divWrap = (schema: ISchema) => {
return {
@ -59,6 +60,7 @@ FormItem.Designer = (props) => {
const compile = useCompile();
const collectionField = getField(fieldSchema['name']) || getCollectionJoinField(fieldSchema['x-collection-field']);
const interfaceConfig = getInterface(collectionField?.interface);
const validateSchema = interfaceConfig?.['validateSchema']?.(fieldSchema);
const originalTitle = collectionField?.uiSchema?.title;
const targetFields = collectionField?.target ? getCollectionFields(collectionField.target) : [];
const isSubFormAssocitionField = field.address.segments.includes('__form_grid');
@ -81,6 +83,7 @@ FormItem.Designer = (props) => {
if (fieldSchema['x-read-pretty'] === true) {
readOnlyMode = 'read-pretty';
}
return (
<GeneralSchemaDesigner>
{collectionField && (
@ -201,6 +204,126 @@ FormItem.Designer = (props) => {
}}
/>
)}
{validateSchema && (
<SchemaSettings.ModalItem
title={t('Set validation rules')}
components={{ ArrayCollapse, FormLayout }}
schema={{
type: 'object',
title: t('Set validation rules'),
properties: {
rules: {
type: 'array',
default: fieldSchema?.['x-validator'],
'x-component': 'ArrayCollapse',
'x-decorator': 'FormItem',
'x-component-props': {
accordion: true,
},
maxItems: 3,
items: {
type: 'object',
'x-component': 'ArrayCollapse.CollapsePanel',
'x-component-props': {
header: '{{ t("Validation rule") }}',
},
properties: {
index: {
type: 'void',
'x-component': 'ArrayCollapse.Index',
},
layout: {
type: 'void',
'x-component': 'FormLayout',
'x-component-props': {
labelStyle: {
marginTop: '6px',
},
labelCol: 8,
wrapperCol: 16,
},
properties: {
...validateSchema,
message: {
type: 'string',
title: '{{ t("Error message") }}',
'x-decorator': 'FormItem',
'x-component': 'Input.TextArea',
'x-component-props': {
autoSize: {
minRows: 2,
maxRows: 2
}
}
},
}
},
remove: {
type: 'void',
'x-component': 'ArrayCollapse.Remove',
},
moveUp: {
type: 'void',
'x-component': 'ArrayCollapse.MoveUp',
},
moveDown: {
type: 'void',
'x-component': 'ArrayCollapse.MoveDown',
},
}
},
properties: {
add: {
type: 'void',
title: '{{ t("Add validation rule") }}',
'x-component': 'ArrayCollapse.Addition',
'x-reactions': {
dependencies: ['rules'],
fulfill: {
state: {
disabled: '{{$deps[0].length >= 3}}'
}
}
}
},
}
}
}
} as ISchema}
onSubmit={(v) => {
const rules = [];
for (const rule of v.rules) {
rules.push(_.pickBy(rule, _.identity))
}
const schema = {
['x-uid']: fieldSchema['x-uid'],
};
// return;
// if (['number'].includes(collectionField?.interface) && collectionField?.uiSchema?.['x-component-props']?.['stringMode'] === true) {
// rules['numberStringMode'] = true;
// }
if (['percent'].includes(collectionField?.interface)) {
for (const rule of rules) {
if (!!rule.maxValue || !!rule.minValue) {
rule['percentMode'] = true;
}
if (rule.percentFormat) {
rule['percentFormats'] = true;
}
}
}
const concatValidator = _.concat([], collectionField?.uiSchema?.['x-validator'] || [], rules)
field.validator = concatValidator;
fieldSchema['x-validator'] = rules;
schema['x-validator'] = rules;
dn.emit('patch', {
schema,
});
refresh();
}}
/>
)}
{form && !isSubFormAssocitionField && ['o2o', 'oho', 'obo', 'o2m'].includes(collectionField?.interface) && (
<SchemaSettings.SelectItem
title={t('Field component')}

View File

@ -3,6 +3,12 @@ import { connect, mapReadPretty } from '@formily/react';
import { InputNumber as AntdNumber } from 'antd';
import { ReadPretty } from './ReadPretty';
export const InputNumber = connect(AntdNumber, mapReadPretty(ReadPretty));
export const InputNumber = connect((props) => {
const { onChange, ...others } = props;
const handleChange = (v) => {
onChange(parseFloat(v));
}
return (<AntdNumber onChange={handleChange} {...others} />);
}, mapReadPretty(ReadPretty));
export default InputNumber;

View File

@ -12,10 +12,10 @@ export const Percent = connect(
<InputNumber
{...props}
addonAfter="%"
value={math.round(value * 100, 9)}
value={value ? math.round(value * 100, 9) : null}
onChange={(v: any) => {
if (onChange) {
onChange(math.round(v / 100, 9));
onChange(v ? math.round(v / 100, 9) : null);
}
}}
/>

View File

@ -23,7 +23,6 @@ const useTableColumns = () => {
const field = useField<ArrayField>();
const schema = useFieldSchema();
const { exists, render } = useSchemaInitializer(schema['x-initializer']);
// console.log('useTableColumns', exists);
const columns = schema
.reduceProperties((buf, s) => {
if (isColumnComponent(s)) {
@ -38,7 +37,6 @@ const useTableColumns = () => {
}
}, []);
const dataIndex = collectionFields?.length > 0 ? collectionFields[0].name : s.name;
console.log('useTableColumns', s.name, s, field.value);
return {
title: <RecursionField name={s.name} schema={s} onlyRenderSelf />,
dataIndex,
@ -48,7 +46,6 @@ const useTableColumns = () => {
render: (v, record) => {
const index = field.value?.indexOf(record);
// console.log((Date.now() - start) / 1000);
console.log('useTableColumns.index', index, record);
return (
<RecordIndexProvider index={index}>
<RecordProvider record={record}>