Skip to content

Commit

Permalink
feat: form 新增 useWatch hook (Tencent#1490)
Browse files Browse the repository at this point in the history
  • Loading branch information
honkinglin authored Sep 19, 2022
1 parent c0e35ad commit 3b4ec9e
Show file tree
Hide file tree
Showing 11 changed files with 218 additions and 34 deletions.
13 changes: 9 additions & 4 deletions src/form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import classNames from 'classnames';
import useConfig from '../hooks/useConfig';
import noop from '../_util/noop';
import forwardRefWithStatics from '../_util/forwardRefWithStatics';
import type { TdFormProps } from './type';
import type { TdFormProps, NamePath } from './type';
import type { InternalFormInstance } from './hooks/interface';
import useInstance from './hooks/useInstance';
import useForm from './hooks/useForm';
import useForm, { HOOK_MARK } from './hooks/useForm';
import useWatch from './hooks/useWatch';
import { StyledProps } from '../common';
import FormContext from './FormContext';
import FormItem from './FormItem';
Expand Down Expand Up @@ -58,12 +60,15 @@ const Form = forwardRefWithStatics(
[...formMapRef.current.values()].forEach((formItemRef) => {
formItemRef?.current.resetField();
});
(form as InternalFormInstance)?.getInternalHooks?.(HOOK_MARK).notifyWatch?.([]);
onReset?.({ e });
}

function onFormItemValueChange(changedValue: Record<string, unknown>) {
function onFormItemValueChange(changedValue: Record<string, unknown>, name: NamePath) {
const allFields = formInstance.getFieldsValue(true);
onValuesChange(changedValue, allFields);

(form as InternalFormInstance)?.getInternalHooks?.(HOOK_MARK).notifyWatch?.(name);
}

function onKeyDownHandler(e: React.KeyboardEvent<HTMLFormElement>) {
Expand Down Expand Up @@ -108,7 +113,7 @@ const Form = forwardRefWithStatics(
</FormContext.Provider>
);
},
{ useForm, FormItem, FormList },
{ useForm, useWatch, FormItem, FormList },
);

Form.displayName = 'Form';
Expand Down
4 changes: 2 additions & 2 deletions src/form/FormContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { TdFormProps, TdFormListProps } from './type';
import { TdFormProps, TdFormListProps, NamePath } from './type';
import { FormItemInstance } from './FormItem';

const FormContext = React.createContext<{
Expand All @@ -17,7 +17,7 @@ const FormContext = React.createContext<{
rules: TdFormProps['rules'];
errorMessage: TdFormProps['errorMessage'];
formMapRef: React.RefObject<Map<any, React.RefObject<FormItemInstance>>>;
onFormItemValueChange: (changedValue: Record<string, unknown>) => void;
onFormItemValueChange: (changedValue: Record<string, unknown>, name: NamePath) => void;
}>({
labelWidth: '100px',
labelAlign: 'right',
Expand Down
11 changes: 6 additions & 5 deletions src/form/FormItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {
CloseCircleFilledIcon as TdCloseCircleFilledIcon,
ErrorCircleFilledIcon as TdErrorCircleFilledIcon,
} from 'tdesign-icons-react';

import useConfig from '../hooks/useConfig';
import useGlobalIcon from '../hooks/useGlobalIcon';
import type { TdFormItemProps, ValueType, FormItemValidateMessage } from './type';
import type { TdFormItemProps, ValueType, FormItemValidateMessage, NamePath } from './type';
import { StyledProps } from '../common';
import { validate as validateModal } from './formModel';
import { useFormContext, useFormListContext } from './FormContext';
Expand All @@ -24,7 +25,7 @@ export interface FormItemProps extends TdFormItemProps, StyledProps {
}

export interface FormItemInstance {
name?: string | number | Array<string | number>;
name?: NamePath;
value?: any;
getValue?: Function;
setValue?: Function;
Expand Down Expand Up @@ -350,12 +351,12 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((props, ref) => {
} else {
formListValue[name] = formValue;
}
onFormItemValueChange?.({ [formListName]: formListValue });
onFormItemValueChange?.({ [formListName]: formListValue }, name);
} else if (Array.isArray(name)) {
const fieldValue = name.reduceRight((prev, curr) => ({ [curr]: prev }), formValue);
onFormItemValueChange?.({ ...fieldValue });
onFormItemValueChange?.({ ...fieldValue }, name);
} else {
onFormItemValueChange?.({ [name as string]: formValue });
onFormItemValueChange?.({ [name as string]: formValue }, name);
}
}

Expand Down
5 changes: 5 additions & 0 deletions src/form/_example/base.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ const { FormItem } = Form;
export default function BaseForm() {
const [form] = Form.useForm();

const name = Form.useWatch('name', form);
const gender = Form.useWatch('gender', form);
console.log('name', name);
console.log('gender', gender);

const onSubmit = (e) => {
console.log(e);
if (e.validateResult === true) {
Expand Down
8 changes: 4 additions & 4 deletions src/form/form.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ className | String | - | 类名 | N
style | Object | - | 样式,Typescript:`React.CSSProperties` | N
clearValidate | `(fields?: Array<keyof FormData>)` | \- | \-
currentElement | \- | `HTMLFormElement` | \-
getFieldValue | `(field: keyof FormData)` | `unknown` | \-
getFieldsValue | \- | `getFieldsValue<FormData>` | [see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`interface getFieldsValue<T>{ (nameList: true): T; (nameList: string[]): Record<keyof T, unknown>;}`<br/>
getFieldValue | `(field: NamePath) ` | `unknown` | \-
getFieldsValue | \- | `getFieldsValue<FormData>` | [see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`interface getFieldsValue<T>{ (nameList: true): T; (nameList: any[]): Record<keyof T, unknown>;}`<br/>
reset | `(params?: FormResetParams<FormData>)` | \- | [see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`interface FormResetParams<FormData> { type?: 'initial' | 'empty'; fields?: Array<keyof FormData> }`<br/>
setFields | `(fields: FieldData[])` | \- | Typescript:`(fields: FieldData[]) => void` `interface FieldData { name: string; value?: unknown, status?: string, validateMessage?: { type?: string, message?: string } }`[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)
setFields | `(fields: FieldData[])` | \- | Typescript:`(fields: FieldData[]) => void` `interface FieldData { name: NamePath; value?: unknown, status?: string, validateMessage?: { type?: string, message?: string } }`[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)
setFieldsValue | `(field: Data)` | \- | \-
setValidateMessage | `(message: FormValidateMessage<FormData>)` | \- | [see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`type FormValidateMessage<FormData> = { [field in keyof FormData]: FormItemValidateMessage[] }`<br/><br/>`interface FormItemValidateMessage { type: 'warning' | 'error'; message: string }`<br/>
submit | `(params?: { showErrorMessage?: boolean })` | \- | \-
Expand All @@ -58,7 +58,7 @@ initialData | String / Number / Object / Array | - | Typescript:`InitialData`
label | TNode | '' | Typescript:`string | TNode`[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N
labelAlign | String | - | options:left/right/top | N
labelWidth | String / Number | - | \- | N
name | String / Number / Array | - | Typescript:`string | number | Array<string | number>` | N
name | String / Number / Array | - | Typescript:`NamePath` `type NamePath = string | number | Array<string | number>`[see more ts definition](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts) | N
requiredMark | Boolean | undefined | \- | N
rules | Array | - | Typescript:`Array<FormRule>` | N
showErrorMessage | Boolean | undefined | \- | N
Expand Down
34 changes: 30 additions & 4 deletions src/form/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,32 @@

{{ form-list }}

## Hooks

### Form.useForm

创建 Form 实例,用于管理所有数据状态。


### Form.useWatch

用于直接获取 form 中字段对应的值。

```js
const Demo = () => {
const [form] = Form.useForm();
const userName = Form.useWatch('username', form);

return (
<Form form={form}>
<Form.Item name="username">
<Input />
</Form.Item>
</Form>
);
};
```

## FAQ

### 为什么被 FormItem 包裹的组件 value、defaultValue 没有效果?
Expand Down Expand Up @@ -80,10 +106,10 @@ className | String | - | 类名 | N
style | Object | - | 样式,TS 类型:`React.CSSProperties` | N
clearValidate | `(fields?: Array<keyof FormData>)` | \- | 清空校验结果。可使用 fields 指定清除部分字段的校验结果,fields 值为空则表示清除所有字段校验结果。清除邮箱校验结果示例:`clearValidate(['email'])`
currentElement | \- | `HTMLFormElement` | 获取 form dom 元素
getFieldValue | `(field: keyof FormData)` | `unknown` | 获取单个字段值
getFieldsValue | \- | `getFieldsValue<FormData>` | 获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有表单数据。[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`interface getFieldsValue<T>{ (nameList: true): T; (nameList: string[]): Record<keyof T, unknown>;}`<br/>
getFieldValue | `(field: NamePath) ` | `unknown` | 获取单个字段值
getFieldsValue | \- | `getFieldsValue<FormData>` | 获取一组字段名对应的值,当调用 getFieldsValue(true) 时返回所有表单数据。[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`interface getFieldsValue<T>{ (nameList: true): T; (nameList: any[]): Record<keyof T, unknown>;}`<br/>
reset | `(params?: FormResetParams<FormData>)` | \- | 重置表单,表单里面没有重置按钮`<button type=\"reset\" />`时可以使用该方法,默认重置全部字段为空,该方法会触发 `reset` 事件。<br />如果表单属性 `resetType='empty'` 或者 `reset.type='empty'` 会重置为空;<br />如果表单属性 `resetType='initial'` 或者 `reset.type='initial'` 会重置为表单初始值。<br />`reset.fields` 用于设置具体重置哪些字段,示例:`reset({ type: 'initial', fields: ['name', 'age'] })`[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`interface FormResetParams<FormData> { type?: 'initial' | 'empty'; fields?: Array<keyof FormData> }`<br/>
setFields | `(fields: FieldData[])` | \- | 设置多组字段状态。TS 类型:`(fields: FieldData[]) => void` `interface FieldData { name: string; value?: unknown, status?: string, validateMessage?: { type?: string, message?: string } }`[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)
setFields | `(fields: FieldData[])` | \- | 设置多组字段状态。TS 类型:`(fields: FieldData[]) => void` `interface FieldData { name: NamePath; value?: unknown, status?: string, validateMessage?: { type?: string, message?: string } }`[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)
setFieldsValue | `(field: Data)` | \- | 设置表单字段值
setValidateMessage | `(message: FormValidateMessage<FormData>)` | \- | 设置自定义校验结果,如远程校验信息直接呈现。注意需要在组件挂载结束后使用该方法。`FormData` 指表单数据泛型。[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts)。<br/>`type FormValidateMessage<FormData> = { [field in keyof FormData]: FormItemValidateMessage[] }`<br/><br/>`interface FormItemValidateMessage { type: 'warning' | 'error'; message: string }`<br/>
submit | `(params?: { showErrorMessage?: boolean })` | \- | 提交表单,表单里面没有提交按钮`<button type=\"submit\" />`时可以使用该方法。`showErrorMessage` 表示是否在提交校验不通过时显示校验不通过的原因,默认显示。该方法会触发 `submit` 事件
Expand All @@ -102,7 +128,7 @@ initialData | String / Number / Object / Array | - | 表单初始数据,重置
label | TNode | '' | 字段标签名称。TS 类型:`string | TNode`[通用类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/common.ts) | N
labelAlign | String | - | 表单字段标签对齐方式:左对齐、右对齐、顶部对齐。默认使用 Form 的对齐方式,优先级高于 Form.labelAlign。可选项:left/right/top | N
labelWidth | String / Number | - | 可以整体设置标签宽度,优先级高于 Form.labelWidth | N
name | String / Number / Array | - | 表单字段名称。TS 类型:`string | number | Array<string | number>` | N
name | String / Number / Array | - | 表单字段名称。TS 类型:`NamePath` `type NamePath = string | number | Array<string | number>`[详细类型定义](https://github.com/Tencent/tdesign-react/blob/develop/src/form/type.ts) | N
requiredMark | Boolean | undefined | 是否显示必填符号(*),优先级高于 Form.requiredMark | N
rules | Array | - | 表单字段校验规则。TS 类型:`Array<FormRule>` | N
showErrorMessage | Boolean | undefined | 校验不通过时,是否显示错误提示信息,优先级高于 `Form.showErrorMessage` | N
Expand Down
16 changes: 16 additions & 0 deletions src/form/hooks/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { NamePath, FormInstanceFunctions } from '../type';

export type Store = Record<string, any>;

export type WatchCallBack = (values: Store, namePathList: NamePath) => void;

export interface InternalHooks {
notifyWatch: (name: NamePath) => void;
registerWatch: (callback: WatchCallBack) => () => void;
}

export interface InternalFormInstance extends FormInstanceFunctions {
_init?: boolean;

getInternalHooks?: (secret: Symbol) => InternalHooks | null;
}
94 changes: 89 additions & 5 deletions src/form/hooks/useForm.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,92 @@
import { useRef } from 'react';
import { FormInstanceFunctions } from '../type';
import { useState, useRef } from 'react';
import type { NamePath } from '../type';
import type { WatchCallBack, InternalHooks, InternalFormInstance, Store } from './interface';
import log from '../../_common/js/log';

export default function useForm() {
const form = useRef<FormInstanceFunctions>({});
export const HOOK_MARK = Symbol('TD_FORM_INTERNAL_HOOKS');

return [form.current];
// TODO 后续将所有实例函数迁移到 FormStore 内统一管理
class FormStore {
private store: Store = {};

private forceRootUpdate: () => void;

constructor(forceRootUpdate: () => void) {
this.forceRootUpdate = forceRootUpdate;
}

public getForm = (): InternalFormInstance => ({
submit: null,
reset: null,
validate: null,
validateOnly: null,
clearValidate: null,
setFields: null,
setFieldsValue: null,
setValidateMessage: null,
getFieldValue: null,
getFieldsValue: null,
_init: true,

getInternalHooks: this.getInternalHooks,
});

private getInternalHooks = (key: Symbol): InternalHooks | null => {
if (key === HOOK_MARK) {
return {
notifyWatch: this.notifyWatch,
registerWatch: this.registerWatch,
};
}

log.warn('Form', '`getInternalHooks` is internal usage. Should not call directly.');
return null;
};

private watchList: WatchCallBack[] = [];

private registerWatch: InternalHooks['registerWatch'] = (callback) => {
this.watchList.push(callback);

return () => {
this.watchList = this.watchList.filter((fn) => fn !== callback);
};
};

private notifyWatch = (namePath: NamePath = []) => {
// No need to cost perf when nothing need to watch
if (this.watchList.length) {
const values = this.getFieldsValue?.(namePath);

this.watchList.forEach((callback) => {
callback(values, namePath);
});
}
};

// TODO 暂时先从组件初始化时外部 merge 覆盖相关 form 操作函数
private getFieldsValue = null;
}

export default function useForm(form?: InternalFormInstance) {
const formRef = useRef<InternalFormInstance>({});
const [, forceUpdate] = useState({});

// eslint-disable-next-line
if (!formRef.current._init) {
if (form) {
formRef.current = form;
} else {
// Create a new FormStore if not provided
const forceReRender = () => {
forceUpdate({});
};

const formStore: FormStore = new FormStore(forceReRender);

formRef.current = formStore.getForm();
}
}

return [formRef.current];
}
17 changes: 11 additions & 6 deletions src/form/hooks/useInstance.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import isEmpty from 'lodash/isEmpty';
import isFunction from 'lodash/isFunction';
import merge from 'lodash/merge';
import type { TdFormProps, FormValidateResult, FormResetParams, FormValidateMessage, AllValidateResult } from '../type';
import type {
TdFormProps,
FormValidateResult,
FormResetParams,
FormValidateMessage,
AllValidateResult,
NamePath,
} from '../type';
import useConfig from '../../hooks/useConfig';

function getMapValue(
name: string | number | Array<string | number>,
formMapRef: React.MutableRefObject<Map<any, any>>,
) {
function getMapValue(name: NamePath, formMapRef: React.MutableRefObject<Map<any, any>>) {
// 提取所有 map key
const mapKeys = [...formMapRef.current.keys()];
// 转译为字符串后比对 key 兼容数组格式
Expand Down Expand Up @@ -140,7 +144,7 @@ export default function useInstance(props: TdFormProps, formRef, formMapRef: Rea
}

// 对外方法,获取对应 formItem 的值
function getFieldValue(name: string | number | Array<string | number>) {
function getFieldValue(name: NamePath) {
if (!name) return null;

const formItemRef = getMapValue(name, formMapRef);
Expand Down Expand Up @@ -250,5 +254,6 @@ export default function useInstance(props: TdFormProps, formRef, formMapRef: Rea
getFieldValue,
getFieldsValue,
currentElement: formRef.current,
getCurrentElement: () => formRef.current,
};
}
40 changes: 40 additions & 0 deletions src/form/hooks/useWatch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import type { NamePath } from '../type';
import type { InternalFormInstance } from './interface';
import { HOOK_MARK } from './useForm';

export default function useWatch(fields: NamePath, form: InternalFormInstance) {
const [value, setValue] = useState<any>();
const valueStr = useMemo(() => JSON.stringify(value), [value]);
const valueStrRef = useRef(valueStr);

// eslint-disable-next-line
const isValidForm = form && form._init;

useEffect(() => {
if (!isValidForm) return;

const { getFieldValue, getInternalHooks } = form;
const { registerWatch } = getInternalHooks(HOOK_MARK);

// eslint-disable-next-line
const cancelRegister = registerWatch((store) => {
const newValue = getFieldValue(fields);
const nextValueStr = JSON.stringify(newValue);

// Compare stringify in case it's nest object
if (valueStrRef.current !== nextValueStr) {
valueStrRef.current = nextValueStr;
setValue(newValue);
}
});

const initialValue = getFieldValue(fields);
setValue(initialValue);

return cancelRegister;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return value;
}
Loading

0 comments on commit 3b4ec9e

Please sign in to comment.