Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 44 additions & 30 deletions packages/components/form/FormItem.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { get, isEqual, isFunction, isObject, isString, set, unset } from 'lodash-es';
import React, { forwardRef, ReactNode, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import {
CheckCircleFilledIcon as TdCheckCircleFilledIcon,
CloseCircleFilledIcon as TdCloseCircleFilledIcon,
ErrorCircleFilledIcon as TdErrorCircleFilledIcon,
} from 'tdesign-icons-react';
import { get, isEqual, isFunction, isObject, isString, set, unset } from 'lodash-es';

import useConfig from '../hooks/useConfig';
import useDefaultProps from '../hooks/useDefaultProps';
import useGlobalIcon from '../hooks/useGlobalIcon';
import { useLocaleReceiver } from '../locale/LocalReceiver';
import { TD_CTRL_PROP_MAP, ValidateStatus } from './const';
import { NATIVE_INPUT_COMP, TD_CTRL_PROP_MAP, ValidateStatus } from './const';
import { formItemDefaultProps } from './defaultProps';
import { useFormContext, useFormListContext } from './FormContext';
import { parseMessage, validate as validateModal } from './formModel';
Expand Down Expand Up @@ -490,40 +490,54 @@ const FormItem = forwardRef<FormItemInstance, FormItemProps>((originalProps, ref
<div className={`${classPrefix}-form__controls-content`}>
{React.Children.map(children, (child, index) => {
if (!child) return null;
if (!React.isValidElement(child)) return child;

let ctrlKey = 'value';
const childType = child.type;
const isCustomComp = typeof childType === 'object' || typeof childType === 'function';
// @ts-ignore
const componentName = isCustomComp ? childType.displayName : childType;

if (React.isValidElement(child)) {
// @ts-ignore
const componentName = child.type?.displayName;
if (componentName === 'FormItem') {
return React.cloneElement(child, {
// @ts-ignore
ref: (el) => {
if (!el) return;
innerFormItemsRef.current[index] = el;
},
});
}
ctrlKey = TD_CTRL_PROP_MAP.get(componentName) || 'value';
const childProps = child.props as any;
if (componentName === 'FormItem') {
return React.cloneElement(child, {
disabled: disabledFromContext,
readOnly: readOnlyFromContext,
...childProps,
[ctrlKey]: formValue,
onChange: (value: any, ...args: any[]) => {
const newValue = valueFormat ? valueFormat(value) : value;
updateFormValue(newValue, true, true);
childProps?.onChange?.call?.(null, value, ...args);
},
onBlur: (value: any, ...args: any[]) => {
handleItemBlur();
childProps?.onBlur?.call?.(null, value, ...args);
// @ts-ignore
ref: (el) => {
if (!el) return;
innerFormItemsRef.current[index] = el;
},
});
}
return child;

const childProps = child.props as any;
const commonProps = {
disabled: disabledFromContext,
readOnly: readOnlyFromContext,
...childProps,
};

if (!isCustomComp && !NATIVE_INPUT_COMP.includes(componentName)) {
return React.cloneElement(child, commonProps);
}

let ctrlKey = 'value';
if (isCustomComp) {
ctrlKey = TD_CTRL_PROP_MAP.get(componentName) || 'value';
}

return React.cloneElement(child, {
disabled: disabledFromContext,
readOnly: readOnlyFromContext,
...childProps,
[ctrlKey]: formValue,
onChange: (value: any, ...args: any[]) => {
const newValue = valueFormat ? valueFormat(value) : value;
updateFormValue(newValue, true, true);
childProps?.onChange?.call?.(null, value, ...args);
},
onBlur: (value: any, ...args: any[]) => {
handleItemBlur();
childProps?.onBlur?.call?.(null, value, ...args);
},
});
})}
{renderSuffixIcon()}
</div>
Expand Down
5 changes: 5 additions & 0 deletions packages/components/form/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,8 @@ export const TD_DEFAULT_VALUE_MAP = (() => {

return map;
})();

/**
* 原生支持 value 属性的组件
*/
export const NATIVE_INPUT_COMP = ['input', 'textarea', 'select', 'progress'];
52 changes: 41 additions & 11 deletions packages/components/form/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@

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


### Form.useWatch

用于直接获取 form 中字段对应的值。
Expand All @@ -52,34 +51,65 @@ const Demo = () => {

## FAQ

### 为什么被 FormItem 包裹的组件 valuedefaultValue 没有效果?
### 为什么被 FormItem 包裹的组件 `value`、`defaultValue` 没有效果?

Form 组件设计的初衷是为了解放开发者配置大量的 `value``onChange` 受控属性,所以 Form.FormItem 被设计成需要拦截嵌套组件的受控属性,如需定义初始值请使用 `initialData` 属性
Form 的设计初衷是自动托管表单字段的 `value``onChange`,FormItem 会向包裹的第一个组件注入状态,即内部组件自身的 `defaultValue`、`value` 将被拦截,不会生效。如果需要设置初始值,应该使用 FormItem 的 `initialData`。

由于 Form.FormItem 只会拦截第一层子节点的受控属性,所以如不希望 Form.FormItem 拦截受控属性希望自行管理 state 的话,可以在 Form.FormItem 下包裹一层 `div` 节点脱离 Form.FormItem 的代理,但同时也会失去 Form 组件的校验能力。
```js
<FormItem name="ui" label="组件库" initialData="TDesign">
<Input />
</FormItem>
```

如果第一层组件不支持 `value` 属性,会导致 Form 无法接管组件的行为:

```js
// ❌ div 的 value 无意义,Form 部分 API 失效
<FormItem name="ui" label="组件库" >
<div style={{ border: '1px dotted blue', padding: 5 }}>
<Input />
</div>
</FormItem>
```

如果想要自定义排版样式,可以考虑下面的写法(类似 [#自定义表单控件](#自定义表单控件) 示例的逻辑):

```js
// ✅ value 自动会传递给 input,Form 相关 API 正常
const CustomInput = (props) => (
<div style={{ border: '1px dotted blue', padding: 5 }}>
<Input {...props} />
</div>
);

<FormItem name="ui" label="组件库" initialData="TDesign">
<CustomInput />
</FormItem>
```

### 我只想要 Form 组件的布局效果,校验能力我自己业务来实现可以吗?

可以的,Form 的校验能力只跟 `name` 属性关联,不指定 Form.FormItem 的 `name` 属性是可以当成布局组件来使用的,甚至可以实现各种嵌套自定义内容的布局效果
可以,表单的校验和存储能力只跟 `name` 属性关联(但无论有无 `name`,第一层子节点的 `value` 和 `onChange` 目前依旧会被拦截)

```js
// 可以单独使用 FormItem 组件
<Form.FormItem label="姓名">
<FormItem label="姓名">
<div>可以任意定制内容</div>
<Input />
<div>可以任意定制内容</div>
</Form.FormItem>
</FormItem>
```

### getFieldsValue 返回的数据如何支持嵌套数据结构?
- 如果仅需要展示信息或纯样式布局,更推荐使用 `Description` 组件。

### `getFieldsValue` 返回的数据如何支持嵌套数据结构?

将 `name` 设置成数组形式可以支持嵌套数据结构。

```js
// ['user', 'name'] => { user: { name: '' } }
<Form.FormItem label="姓名" name={['user', 'name']}>
<FormItem label="姓名" name={['user', 'name']}>
<Input />
</Form.FormItem>
</FormItem>
```

## API
Expand Down
26 changes: 11 additions & 15 deletions packages/components/form/hooks/useWatch.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { get, isUndefined } from 'lodash-es';
import { useEffect, useState } from 'react';
import { get, isEqual } from 'lodash-es';

import noop from '../../_util/noop';
import { HOOK_MARK } from './useForm';

import type { NamePath } from '../type';
import type { InternalFormInstance } from './interface';
import { HOOK_MARK } from './useForm';
import noop from '../../_util/noop';

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

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

useEffect(() => {
Expand All @@ -21,22 +21,18 @@ export default function useWatch(name: NamePath, form: InternalFormInstance) {
const cancelRegister = registerWatch(() => {
const allFieldsValue = form.getFieldsValue?.(true);
const newValue = get(allFieldsValue, name);
const nextValueStr = JSON.stringify(newValue);

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

const allFieldsValue = form.getFieldsValue?.(true);
const initialValue = get(allFieldsValue, name);
setValue(JSON.stringify(initialValue));
setValue(initialValue);

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

return isUndefined(value) ? value : JSON.parse(value);
return value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export default {
}

// 替换成对应 demo 文件
source = source.replace(/\{\{\s+(.+)\s+\}\}/g, (demoStr, demoFileName) => {
// 只匹配独立行的 {{ }} 模式,避免影响普通代码块中的内容
source = source.replace(/^[ \t]*\{\{\s+(.+?)\s+\}\}[ \t]*$/gm, (demoStr, demoFileName) => {
const tsxDemoPath = path.resolve(resourceDir, `./_example/${demoFileName}.tsx`);

if (!fs.existsSync(tsxDemoPath)) {
Expand Down
Loading