formily在实际项目中的应用

4,123 阅读7分钟

在一个老项目中,已经有antdForm了,怎么进行两者的整合?

这是我一开始做项目时的一个痛点。因为我们是基于多组合作的,有些组件必须要公用,因为涉及到后期调整。但是人家已经用了antdForm了。

我想借这个话题,来具体分享下我们实际工作中formily的一些应用及痛点,以及一些有意思的bug吧。

一些基本认知

写法

在formily的世界里面,有三种写法:

  • Markup Schema
  • JSON Schema
  • 纯JSX

通常我自己在项目里面不会用到Markup Schema,理由是它的结构限死了,如果要中间在插入一段div的写法,是没法做到的。

纯JSX的写法

import { Form, FormItem, Input } from '@formily/antd';
import { createForm } from '@formily/core';
import { Field } from '@formily/react';
import React, { useMemo } from 'react';

function Test() {
  const form = useMemo(
    () =>
      createForm({
        validateFirst: true, // 只显示第一个错误
        initialValues: {}, // 初始值
        effects() {
          // 事件处理
        },
      }),
    []
  );

  return (
    <Form form={form} layout="vertical">
      <Field name="test" title="测试" component={[Input, { placeholder: '请输入' }]} decorator={[FormItem]} />
      <Field name="xx" hidden />
    </Form>
  );
}

export default Test;

我在上面的代码中加了一个hiddenField,但其实一般情况下是不用加的。有些特殊场景下是需要,譬如说我们监听一个字段的变化,但是这个字段并没有出现在UI中,那这样就无法监听到。即:

setValues进去的字段,不等于会映射到Field里面去。

JSON Schema的写法

import { Form, FormItem, Input } from '@formily/antd';
import { createForm } from '@formily/core';
import { createSchemaField, Field, ISchema } from '@formily/react';
import React, { useMemo } from 'react';

const SchemaField = createSchemaField({
  components: {
    Input,
    FormItem,
  },
  scope: {
    // 作用域,函数的相关处理
  },
});

const jsonSchema: ISchema = {
  type: 'object',
  properties: {
    test: {
      'x-component': 'Input',
      title: '测试',
      'x-component-props': {},
      'x-decorator': 'FormItem',
      'x-decorator-props': {},
    },
  },
};

function Test() {
  const form = useMemo(
    () =>
      createForm({
        validateFirst: true, // 只显示第一个错误
        initialValues: {}, // 初始值
        effects() {
          // 事件处理
        },
      }),
    []
  );

  return (
    <Form form={form} layout="vertical">
      <SchemaField schema={jsonSchema} />
    </Form>
  );
}

export default Test;

可以说,JSON Schema开启了一个新的时代:低代码,就是我们完全可以上面的jsonSchema内容存储到后端,前端只要关心业务逻辑的处理就行了。

image.png

Field分类

  • VoidField
  • Field
  • ObjectField
  • ArrayField

VoidField

可以简单的理解成div这种容器,就是和表单项没关系的,单纯用来UI布局的。所以问题来了,如果尝试给这个元素加一个value的props,是否能拿到该value呢?

// xx: {
//   type: 'void',
//   'x-component': 'MyComponent',
// }
form.setFieldState('xx', field => {
   field.componentProps = {
       ...field.componentProps,
       value: '1111'
   }
})

MyComponent这个组件里面,props.valueundefined

Field

Field就是一个普通表单项Wrapper,通常我们自定义组件,其实和antdForm自定义组件一样,只要接收valueonChange即可。

它比较强大的点,在于Field可以嵌套Field,当然嵌套了之后,路径是进行叠加的,比如说前者的path是a,后者的path是b,那么后一个真实的路径是a.b,当然我们可以重写FieldbasePath来重新指定。

不过在ArrayField里面,就不能使用basePath了,因为会出现在添加、删除这些操作时,出现bug。之前之所以这么用,是因为场景是:Input旁边是Select控件,然后我当时的写法是InputaddonAfter属性是下拉。但其实这个地方应该是要写成两个控件是并排的。

ObjectField

顾名思议,就是可以理解成对象的Field。场景会有以下三种:

  • 多个相同前缀的组合,都是放在一起的,可以变成一起。那样的话,未来前缀调整了,改一处就行了
  • 子表单
  • 动态添加属性(但一般不常见)

ArrayField

这在实际工作中比较常见。譬如说动态的table,亦或是动态的list

它给field赋值了两个常用的方法:

  • push 新增一条数据
  • remove 移除一条数据,根据索引

那么问题来了,下面这种怎么处理?

image.png

批量删除,就需要我们取点巧,譬如说倒着删

/**
 * 删除选中的行
 * @param field ArrayField
 * @param params object
 * @returns undefined
 */
export const removeField = (
  field: ArrayField,
  {
    selectedRowKeys,
    setSelectedRows,
    setSelectedRowKeys,
  }: {
    selectedRowKeys: any[];
    setSelectedRowKeys: (keys: any[]) => void;
    setSelectedRows: (rows: any[]) => void;
  }
) => {
  if (selectedRowKeys.length === 0) {
    message.error('请至少选择一条数据');
    return;
  }
  selectedRowKeys.sort((a, b) => b - a);
  selectedRowKeys.forEach(index => {
    field.remove(index);
  });
  setSelectedRows([]);
  setSelectedRowKeys([]);
};

一些前提工作

处理错误

mouseover的时候才显示错误

为什么要这么做?首先就是让它出现在下面一行,在table的表单项是不太合适的,然后@formily/antdForm提供了popover的模式,它的方式是错误时,出现气泡。

但是针对toB的项目,大量的表单项,如果60%都是必填项,一开始直接点击保存,会有大量的气泡层出来,且滚动条滚动时,一直固定在那儿,贼丑。

所以我们的方案是:不显示error message,只是把框变红,鼠标hover的时候,显示error message

做法是这样的:

入口处调用:

formilyErrorHelper(theme['ant-prefix']);  // 传进去antd的前缀

上面的formilyErrorHelper方法的实现思路,大家简单看一下下面的图片就能理解了:

image.png

鼠标移上去的时候,复制错误的div结构到doument元素下,移开的时候,移除那个dom元素。

滚动到指定位置

  document
    .querySelector(`.${antPrefix}-formily-item.${antPrefix}-formily-item-error`)
    ?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });

重写max的规则

其实我不太喜欢max这个规则,主要是来自于它的不确定性,一会儿是长度,一会儿是最大值。然后这个取决于这个字段的类型是字符串,还是数字。

大家都知道,前端和后端,有些字段的类型可能是不一样的,所以这时候设置的max就容易出问题了。那么我索性直接让这个max成为最大长度的代名词,不管是什么类型。

const isValidateEmpty = (value: any) => {
  if (isArr(value)) {
    for (let i = 0; i < value.length; i++) {
      if (isValid(value[i])) return false;
    }
    return true;
  } else {
    //compat to draft-js
    if (value?.getCurrentContent) {
      /* istanbul ignore next */
      return !value.getCurrentContent()?.hasText();
    }
    return isEmpty(value);
  }
};

registerValidateRules({
  // 重写max的规则
  // @ts-ignore
  max(value, rule) {
    if (isValidateEmpty(value)) return '';
    const length = (value + '').length;
    const max = Number(rule.max);
    return length > max ? rule.message : '';
  },
});

禁用态能复制

这个功能还是比较带有业务性的,不具有通用性,但能给小伙伴们一些想法吧。

譬如这样的下拉框:

image.png

作为使用方来说,有些情况下,想快速复制,都没办法。因此我们这边搞了一个方式,就是鼠标移入到label上时,会出来一个复制,点击就可以copy。

image.png

实现代码:

import I18N from '@/utils/I18N';
import { message } from '@dzg/common-utils';

// eslint-disable-next-line no-undef
let delay: NodeJS.Timeout;
// eslint-disable-next-line no-undef
let interval: NodeJS.Timeout;

function copy(text: string) {
  var textArea = document.createElement('textarea');
  textArea.value = text;
  document.body.appendChild(textArea);
  textArea.select();

  try {
    var successful = document.execCommand('copy');
    // var msg = successful ? 'successful' : 'unsuccessful';

    if (successful) {
      message.success(I18N.utils.CopyHelper.copiedTo);
    }
  } catch (err) {
    console.log(err);
  }

  document.body.removeChild(textArea);
}

function addCopyButton(target: HTMLElement, text: string, antPrefix: string) {
  let formItemLabel = target.querySelector(`.${antPrefix}-formily-item-label`) as HTMLElement;
  if (formItemLabel.querySelector('input')) {
    return;
  }
  let copyButton = document.createElement('a');
  copyButton.className = 'form-item-copy-button';
  copyButton.style.fontSize = '12px';
  // copyButton.style.lineHeight = '20px';
  copyButton.style.marginLeft = '10px';
  copyButton.text = I18N.HeaderInfo.BusinessNoFee.copy;
  copyButton.onclick = () => {
    copy(text);
  };
  if (!formItemLabel.querySelector('.form-item-copy-button')) {
    formItemLabel.appendChild(copyButton);
  }
  target.addEventListener(
    'mouseleave',
    () => {
      if (copyButton.parentNode === target.querySelector(`.${antPrefix}-formily-item-label`)) {
        clearInterval(interval);
        requestAnimationFrame(() => {
          (target.querySelector(`.${antPrefix}-formily-item-label`) as HTMLElement).removeChild(copyButton);
        });
      }
    },
    { once: true }
  );
}

export const formilyCopyHelper = (antPrefix: string) => {
  document.addEventListener('mouseover', event => {
    if (delay) {
      clearTimeout(delay);
    }
    const func = () => {
      if (interval) {
        clearInterval(interval);
      }
      const target = event.target as HTMLElement;
      if (target) {
        const formItem = (((event as any).path as HTMLElement[]) || []).find(
          item =>
            item.className &&
            item.className.split &&
            item.className.split(' ').includes(`${antPrefix}-formily-item`) &&
            item.className.split(' ').includes(`${antPrefix}-formily-item-layout-vertical`) &&
            !item.className.split(' ').includes('formily-item-no-copy')
        );
        if (formItem && window.getComputedStyle(formItem, null).flexDirection === 'row') {
          //内部不存在其他formItem的formItem
          if (!formItem.querySelector(`.${antPrefix}-formily-item`)) {
            let formItemLabel = formItem.querySelector(`.${antPrefix}-formily-item-label`);
            if (formItemLabel) {
              let formItemContent = formItem.querySelector(`.${antPrefix}-formily-item-control-content`);

              //输入框group
              if (formItemContent?.querySelector(`.${antPrefix}-input-group-wrapper`)) {
                let input = formItemContent?.querySelector(`.${antPrefix}-input`) as HTMLInputElement;
                if (input.disabled && input.value) {
                  addCopyButton(formItem, input.value, antPrefix);
                }
              }

              //下拉框和autoComplete
              else if (formItemContent?.querySelector(`.${antPrefix}-select`)) {
                let select = formItemContent?.querySelector(`.${antPrefix}-select`);

                if (select?.classList.value.includes(`${antPrefix}-select-disabled`)) {
                  if (select?.classList.value.includes(`${antPrefix}-select-multiple`)) {
                    //多选下拉框
                    let selectionItemContents = formItemContent?.querySelectorAll(
                      `.${antPrefix}-select-selection-item-content`
                    );
                    let content = Array.prototype.slice
                      .call(selectionItemContents)
                      .map(item => item.innerHTML)
                      .join(',');
                    addCopyButton(formItem, content, antPrefix);
                  } else {
                    let selectionItem = formItemContent?.querySelector(`.${antPrefix}-select-selection-item`);

                    let selectionSearchInput = formItemContent?.querySelector(
                      `.${antPrefix}-select-selection-search-input`
                    ) as HTMLInputElement;
                    if (selectionItem && selectionItem.innerHTML) {
                      addCopyButton(formItem, selectionItem.innerHTML, antPrefix);
                    } else if (selectionSearchInput && selectionSearchInput.value) {
                      addCopyButton(formItem, selectionSearchInput.value, antPrefix);
                    }
                  }
                }
              }

              //数字输入框
              else if (formItemContent?.querySelector(`.${antPrefix}-input-number-input`)) {
                let input = formItemContent?.querySelector(`.${antPrefix}-input-number-input`) as HTMLInputElement;
                if (input.disabled && input.value) {
                  addCopyButton(formItem, input.value, antPrefix);
                }
              }

              //输入框和时间输入框
              else if (formItemContent?.querySelector(`.${antPrefix}-input`)) {
                let input = formItemContent?.querySelector(`.${antPrefix}-input`) as HTMLInputElement;
                if (input.disabled && input.value) {
                  addCopyButton(formItem, input.value, antPrefix);
                }
              }
            }
          }
        }
      }
    };
    delay = setTimeout(func, 150);
  });
};

formilyErrorHelper,上面的formilyCopyHelper也是在入口处调用一下就行了。(当然这个方法可能还是需要补全的,只是考虑了大部分的组件,有些可能没考虑到)

其实这个只能满足大部分的场景,当场景是Table时,禁用态的复制,似乎不太好处理,此时要么就是加title,要么变成不用复制的阅读态(不然会很丑)。

阅读态可复制

这个功能也是比较带有业务性的,不具有通用性,但是其他小伙伴也可能会碰到类似的需求。

老实说,我个人觉得@formily/antd除了最基础的一些容器组件(如FormItemGrid),其他的在业务层面应用还是有点浅(这个浅是指preview这一块上面,按我的理解,大多数是需要定制开发的)

我们的需求是这样的:

image.png

image.png

所以我们得要自己实现一套针对阅读态的组件。

antdForm共存

一些思考

对于antd的组件来说,如果接收formilyonChange,其实也是改了formily里面的值。

然后如果组件不接收onChange,那一般会接收外部传进去的form属性,然后去操作setFieldssetFieldsValuegetFieldsValue等函数。

那么我们只需要重写那些函数,在里面除了对antd本身的数据操作之外,还有要对formily的数据也要操作。

/**
 * 获取合并后的表单对象
 * 外部这个return值,一般只关注submit和setValues这两个方法
 * @param form formily表单对象
 * @returns obj
 */
export const getMergeForm = (form: IForm<any>) => {
  const [antdForm] = AntdForm.useForm();
  return Object.assign({}, antdForm, {
    setFields: (values: any[]) => {
      antdForm.setFields(values);
      for (let i = 0; i < values.length; i++) {
        let { name, value } = values[i];
        if (Array.isArray(name)) {
          name = name.join('.');
        }
        form.setValuesIn(name, value);
      }
    },
    setFieldsValue: (values: any) => {
      antdForm.setFieldsValue(values);
      for (let key in values) {
        let namePath = key;
        if (Array.isArray(namePath)) {
          namePath = namePath.join('.');
        }
        form.setValuesIn(namePath, values[namePath]);
      }
    },
    getFieldsValue: () => {
      return { ...toJS(form.values), ...antdForm.getFieldsValue() };
    },
    getFieldValue: (path: string) => {
      return form.getValuesIn(path) || antdForm.getFieldValue(path);
    },
    // 提交数据,antd的表单要通过校验
    submit: (): Promise<any> => {
      return Promise.all([antdForm.validateFields(), form.submit()]);
    },
    setValues: (values: any) => {
      antdForm.setFieldsValue(values);
      form.setValues(values);
    },
  });
};

当然没那么简单

实际上在具体应用的时候,还是碰到了一些问题,那么解决方案,通常会自己在外层重新包裹一个组件进行处理。

譬如:

import { ShipperConsignee } from '@dzg/business-component';
import React from 'react';

/**
 * 收发通
 * @param props 属性
 * @returns JSX
 */
const WrapperShipperConsignee = (props: any) => {
  return (
    <ShipperConsignee
      {...props}
      value={{ transceiverList: props.value }}
      onChange={value => {
        props.onChange(value.transceiverList);
        (window as any).isChange = true;
      }}
    />
  );
};

export default WrapperShipperConsignee;

上面的例子是对valueonChange做了二次处理,主要来应对一些特殊的场景。

还有类似这样的:

import React from 'react';
import { observer, useField } from '@formily/react';
import HsCode from '@dzg/business-component/es/goods-info/HsCode';
import CommonFormWrapper from '../CommonFormWrapper';

/**
 * WrapperHsCode
 * @param props 组件属性
 * @returns JSX
 */
function WrapperHsCode(props: any) {
  const field = useField();
  const disabled = field.disabled || false;
  return <CommonFormWrapper wrapper={HsCode} {...props} disabled={disabled} />;
}

export default observer(WrapperHsCode);
import { Form } from 'antd';
import React from 'react';

/**
 * 公共form包裹组件器
 * @param props 组件属性
 * @returns JSX
 */
function CommonFormWrapper(props: any) {
  const { wrapper, form, ...restProps } = props;
  const InnerComp = wrapper;
  return (
    <Form form={form} style={{ width: '100%' }}>
      <InnerComp {...restProps} form={form} />
    </Form>
  );
}

export default CommonFormWrapper;

上面的例子是给组件包一层antdForm,来解决一些三方包里面只有antd的FormItem,导致在处理值有问题。

当然以上的场景只能遇魔杀魔、遇神杀神了,大前提还是不要影响别人的现有代码。

在实际的自定义组件中,useField、useForm、useFormEffects这三个在复杂场景中,还是比较常用的。当然了,别忘记,组件要嵌套observer,不然没法进行响应式变化。

useFormilyForm

这是我在实际业务中抽象的一个hook。拿我们的页面来说,就是头部、中间、右侧栏信息。

中间是一个表单项。头部的数据、中间的数据和右侧的数据,基本都是一个接口里面的(当然有些页面不是在一个接口里面),那么对于中间的表单项来说,无非是需要传入表单数据,然后中间的那个组件需要往外抛一个获取表单数据的方法(因为保存按钮在头部)。

import { createForm, Form, onFieldInputValueChange, onFormMount } from '@formily/core';
import { createSchemaField } from '@formily/react';
import { useImperativeHandle, useMemo, useState } from 'react';
import { getMergeForm } from '../utils';
import { SchemaComponents, SchemaScope } from '../utils/schemaHelper';
import _ from 'lodash';

/**
 * formily form的通用处理
 * @param ref Ref
 * @param initialValues 初始值
 * @param formEffect 事件处理回调
 * @param initFormData 初始化表单数据回调
 * @returns 包含表单对象、antd表单对象、SchemaField的大对象
 */
function useFormilyForm(
  ref: any,
  initialValues = {},
  formEffect?: () => void,
  initFormData?: (form: Form<any>, data: any, reduxData: any) => void
) {
  // 用来判断是否已经加载了数据
  const [hasLoadData, setHasLoadData] = useState(false);
  const [loadData, setLoadData] = useState<any>({});

  const form: Form<any> = useMemo(
    () =>
      createForm({
        validateFirst: true,
        initialValues,
        effects() {
          onFormMount(form => {
            // @ts-ignore
            window.__MyForm__ = form; // 给自己留个后门,比如说做个chrome插件,给页面赋值,如果必填项太多,可以做一个初始数据
          });
          onFieldInputValueChange('*', () => {
            // 在切换不同的头部tab时,需要判断当前表单页是否被修改,如果有修改,则要弹窗提示用户是否切走
            (window as any).isChange = true;
          });
          formEffect && formEffect();
        },
      }),
    []
  );

  const mergedAntdForm = getMergeForm(form);

  useImperativeHandle(ref, () => ({
    // 历史原因,需要区分出是表单的数据,还是redux的数据
    initFormData: (data: any, reduxData: any, key?: string) => {
      let finalData = data;
      if (key) {
        finalData = _.get(data, key);
      }
      initFormData && initFormData(form, finalData, reduxData);
      mergedAntdForm.setValues(finalData);
      setHasLoadData(true);
      setLoadData({
        ...finalData,
        ...reduxData,
      });
    },
    // 往外抛一个方法
    getFormData: () => {
      return ((mergedAntdForm.submit() as any) as Promise<any>).then(([, data2]) => {
        return data2;
      });
    },
    setFields: mergedAntdForm.setFields,
  }));

  const SchemaField = createSchemaField({
    // @ts-ignore
    components: SchemaComponents, // 所有的组件
    scope: {
      form: mergedAntdForm,
      ...SchemaScope,
    },
  });

  return {
    form,
    mergedAntdForm,
    SchemaField,
    hasLoadData,
    loadData,
  };
}

export default useFormilyForm;

一些注意点

校验出错的时候,也要滚动antd的错误项里面去(如果有错的话)。

/**
 * 将错误滚动到可视区范围内
 * @return undefined
 */
export const scrollIntoView = () => {
  const antPrefix = theme['ant-prefix'];
  document
    .querySelector(`.${antPrefix}-form-item.${antPrefix}-form-item-has-error`)
    ?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
  document
    .querySelector(`.${antPrefix}-formily-item.${antPrefix}-formily-item-error`)
    ?.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' });
};

浅淡Modal的Form

@formily/antd提供了一个FormDialog组件,其实我个人是觉得有些些鸡肋,当然有人会觉得很好,这个个人口味吧。

我说一下点:

  • 其实没必要共享context,应该是内外部相互隔离的,只有在点击确定的时候,Modal内的数据才会同步到外部,当点击取消的时候,放弃Modal内已经变更的数据。
  • 能够传进去一个标识,用来控制这个表单到底是edit,还是disabled,还有preview

所以我自己实现了一个withFormilyModal的高阶函数,实现的有点丑陋,就不放出来了。。其实写法上参考了这个哥们写的:React通用解决方案——表单容器

一些有意思的bug

disabled的情况下,placeholder不应该显示

话说这个点,我觉得其实应该写到HTML规范里面去。

我目前的做法是通过css来简单处理一下。

 input[disabled]::placeholder {
  opacity: 0;
}
textarea[disabled]::placeholder {
  opacity: 0;
}
/* 针对antd的下拉框处理 */
.@{ant-prefix}-select-disabled .@{ant-prefix}-select-selection-placeholder {
  opacity: 0;
}

在联动的时候,如果验证不通过,就不要再联动了

需求如下:

列表里面有长宽高、体积,当长、宽、高变化时,如果长、宽、高校验不通过(比如说小数点超过3位,或者正整数超过多少位),那么体积就不应该再被计算了。

 onFieldInputValueChange('goods.detailList.*.*(length,width,high)', async field => {
      const lengthField: any = field.query('.length').take();
      const widthField: any = field.query('.width').take();
      const highField: any = field.query('.high').take();
      const volume: any = field.query('.volume').take();
      // 长、宽、高进行校验
      await lengthField.validate();
      await widthField.validate();
      await highField.validate();
      if (
        (lengthField.validateStatus && lengthField.validateStatus === 'error') ||
        (widthField.validateStatus && widthField.validateStatus === 'error') ||
        (highField.validateStatus && highField.validateStatus === 'error')
      ) {
        return;
      }
      if (isNumber(lengthField.value) && isNumber(widthField.value) && isNumber(highField.value)) {
        let result = accMul(
          Number(lengthField.value) / 100,
          Number(widthField.value) / 100,
          Number(highField.value) / 100
        );
        if (result < 0.001) {
          result = 0.001;
        }
        if (!_.isFinite(result)) {
          volume.value = '';
        } else {
          volume.value = result.toFixed(3) + '';
        }
        volume.validate(); // 触发校验
      } else {
        if (!volume.value) {
          volume.value = '';
        }
      }
    });
  });

其实这个算是一种场景,可以进行async await的,另外有一种场景是说render一个jsx,但是里面的内容渲染,需要做一个处理,就是校验没通过时,就用上一次的值。

const renderTips = (field: IArrayField) => {
    const value = field.value;
    let validateResult = true;
    for (let i = 0; i < value.length; i++) {
      const item = value[i];
      if (!/^[1-9](\d+)?$/.test(item.count)) {
        validateResult = false;
        break;
      }
      if (validateWeight(item.weight)) {
        validateResult = false;
        break;
      }
      if (validateWeight(item.volume)) {
        validateResult = false;
        break;
      }
    }
    if (validateResult) {
      totalDataRef.current = toJS(value);
    }

    const sumData = getSumData(totalDataRef.current);

    const content = (
      <>
        总件数: {sumData.count || 0} 件 总毛重: {sumData.weight || 0} KGS 总体积: {sumData.volume || 0} CBM
      </>
    );
    return <Alert message={content} type="info" showIcon />;
  };

但老实说,这样写,有一个风险在于这边的校验规则和schema的规则写了两套,尽管可以写一个公共的方法,但是还是有一定的修改风险点吧。我暂时没有想到更好的方案,也许哪天想出了验证器引擎这样的玩意,再来做分享吧。

required的框,输几个空格,没有提示算bug

这个可以在校验规则里面加入whitespace

image.png

但其实加的多了,也比较烦。。所以可以参考我上面的重写max规则的方式,来重写required规则。(大前提是后期修改)

数字输入框允许敲入空格

我一开始拿到这个bug,整个是懵的。。后来看了NumberPicker之后,发出一声感慨:

image.png

table多行的时候,自动滚动到最后一行

就是自增的table,在出来10条后,再新增,就要出现滚动条,然后每次新增,滚动条就要到底部。

/**
 * 增加一行
 * @param field ArrayField
 * @param tableId tableId
 * @returns undefined
 */
export const addField = (field: ArrayField, tableId: string) => {
  field.push({});
  scrollToEnd(tableId);
};

/**
 * 滚动到最后一行
 * @param tableId tableId
 * @returns undefined
 */
export const scrollToEnd = (tableId: string) => {
  setTimeout(() => {
    let d = document.querySelector(`#${tableId} .${theme['ant-prefix']}-table-body`);
    if (d) {
      d.scrollTop = d.scrollHeight;
    }
  }, 0);
};