diff --git a/src/_common b/src/_common index 02c55e8fc3..4bdf9ced2d 160000 --- a/src/_common +++ b/src/_common @@ -1 +1 @@ -Subproject commit 02c55e8fc35facdf53e19c6d5a2661ab12d2a468 +Subproject commit 4bdf9ced2d0b1c3aaf88c035424ef2230d71f396 diff --git a/src/form/Form.tsx b/src/form/Form.tsx index 054513b7b6..8163e3a642 100644 --- a/src/form/Form.tsx +++ b/src/form/Form.tsx @@ -52,7 +52,8 @@ const Form = forwardRefWithStatics( const [form] = useForm(props.form); // 内部与外部共享 form 实例,外部不传则内部创建 const formRef = useRef(); const formMapRef = useRef(new Map()); // 收集所有包含 name 属性 formItem 实例 - const formInstance = useInstance(props, formRef, formMapRef); + const floatingFormDataRef = useRef({}); // 储存游离值的 formData + const formInstance = useInstance(props, formRef, formMapRef, floatingFormDataRef); useImperativeHandle(ref, () => formInstance); Object.assign(form, { ...formInstance }); @@ -103,6 +104,7 @@ const Form = forwardRefWithStatics( rules, disabled, formMapRef, + floatingFormDataRef, onFormItemValueChange, }} > diff --git a/src/form/FormContext.tsx b/src/form/FormContext.tsx index d9027b7bb0..608033526d 100644 --- a/src/form/FormContext.tsx +++ b/src/form/FormContext.tsx @@ -19,6 +19,7 @@ const FormContext = React.createContext<{ rules: TdFormProps['rules']; errorMessage: TdFormProps['errorMessage']; formMapRef: React.RefObject>>; + floatingFormDataRef: React.RefObject>; onFormItemValueChange: (changedValue: Record) => void; }>({ form: undefined, @@ -37,6 +38,7 @@ const FormContext = React.createContext<{ statusIcon: undefined, onFormItemValueChange: undefined, formMapRef: undefined, + floatingFormDataRef: undefined, }); export const useFormContext = () => React.useContext(FormContext); @@ -47,7 +49,7 @@ export const FormListContext = React.createContext<{ name: NamePath; rules: TdFormListProps['rules']; formListMapRef: React.RefObject>>; - initialData: TdFormProps['initialData']; + initialData: TdFormListProps['initialData']; }>({ name: undefined, rules: undefined, diff --git a/src/form/FormItem.tsx b/src/form/FormItem.tsx index 4568fef944..9151499b53 100644 --- a/src/form/FormItem.tsx +++ b/src/form/FormItem.tsx @@ -25,14 +25,14 @@ import { HOOK_MARK } from './hooks/useForm'; import { validate as validateModal, parseMessage } from './formModel'; import { useFormContext, useFormListContext } from './FormContext'; import useFormItemStyle from './hooks/useFormItemStyle'; +import useFormItemInitialData, { ctrlKeyMap } from './hooks/useFormItemInitialData'; import { formItemDefaultProps } from './defaultProps'; -import { ctrlKeyMap, getDefaultInitialData } from './useInitialData'; import { ValidateStatus } from './const'; import useDefaultProps from '../hooks/useDefaultProps'; import { useLocaleReceiver } from '../locale/LocalReceiver'; export interface FormItemProps extends TdFormItemProps, StyledProps { - children?: React.ReactNode | ((form: FormInstanceFunctions) => React.ReactElement); + children?: React.ReactNode | React.ReactNode[] | ((form: FormInstanceFunctions) => React.ReactElement); } export interface FormItemInstance { @@ -62,7 +62,6 @@ const FormItem = forwardRef((originalProps, ref form, colon, layout, - initialData: FromContextInitialData, requiredMark: requiredMarkFromContext, labelAlign: labelAlignFromContext, labelWidth: labelWidthFromContext, @@ -76,12 +75,7 @@ const FormItem = forwardRef((originalProps, ref onFormItemValueChange, } = useFormContext(); - const { - name: formListName, - rules: formListRules, - formListMapRef, - initialData: FormListInitialData, - } = useFormListContext(); + const { name: formListName, rules: formListRules, formListMapRef } = useFormListContext(); const props = useDefaultProps(originalProps, formItemDefaultProps); @@ -105,6 +99,8 @@ const FormItem = forwardRef((originalProps, ref requiredMark = requiredMarkFromContext, } = props; + const { getDefaultInitialData } = useFormItemInitialData(name); + const [, forceUpdate] = useState({}); // custom render state const [freeShowErrorMessage, setFreeShowErrorMessage] = useState(undefined); const [errorList, setErrorList] = useState([]); @@ -114,12 +110,8 @@ const FormItem = forwardRef((originalProps, ref const [needResetField, setNeedResetField] = useState(false); const [formValue, setFormValue] = useState( getDefaultInitialData({ - name, - formListName, children, initialData, - FromContextInitialData, - FormListInitialData, }), ); @@ -325,12 +317,8 @@ const FormItem = forwardRef((originalProps, ref function getResetValue(resetType: string): ValueType { if (resetType === 'initial') { return getDefaultInitialData({ - name, - formListName, children, initialData, - FromContextInitialData, - FormListInitialData, }); } diff --git a/src/form/__tests__/form.test.tsx b/src/form/__tests__/form.test.tsx index 01be9666e7..4e3c277427 100644 --- a/src/form/__tests__/form.test.tsx +++ b/src/form/__tests__/form.test.tsx @@ -5,6 +5,7 @@ import React, { useEffect, useState } from 'react'; import Form, { TdFormProps } from '../index'; import Input from '../../input'; import Button from '../../button'; +import Radio from '../../radio'; import { HelpCircleIcon } from 'tdesign-icons-react'; const { FormItem } = Form; @@ -341,4 +342,60 @@ describe('Form 组件测试', () => { await mockDelay(); expect(container.querySelector('.t-input__extra').innerHTML).toBe('please input username'); }); + + test('动态渲染并初始赋值', () => { + const TestForm = () => { + const [form] = Form.useForm(); + const setMessage = () => { + form.setFieldsValue({ + gender: 'female', + radio2: '3', + }); + }; + + return ( +
+ + + 男性 + 女性 + + + prev.gender !== next.gender}> + {({ getFieldValue }) => { + if (getFieldValue('gender') === 'female') { + return ( + + + 选项三 + + 选项四 + + + + ); + } + return ( + + + 选项一 + 选项二 + + + ); + }} + + + + + +
+ ); + }; + + const { container, getByText } = render(); + fireEvent.click(getByText('设置信息')); + + expect(container.querySelector('.radio-value-3')).toHaveClass('t-is-checked'); + }); }); diff --git a/src/form/_example/form-field-linkage.tsx b/src/form/_example/form-field-linkage.tsx new file mode 100644 index 0000000000..69f7790e74 --- /dev/null +++ b/src/form/_example/form-field-linkage.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import Form from '../Form'; +import Radio from '../../radio'; +import Button from '../../button'; + +const { FormItem } = Form; + +export default function FormExample() { + const [form] = Form.useForm(); + const setMessage = () => { + form.setFieldsValue({ + type: 'cold', + ice: '1', + }); + }; + + return ( +
+ + + 热饮 + 冷饮 + + + prev.type !== next.type}> + {({ getFieldValue }) => { + if (getFieldValue('type') === 'cold') { + return ( + + + 正常冰 + 少冰 + 去冰 + + + ); + } + return null; + }} + + + + + +
+ ); +} diff --git a/src/form/form.md b/src/form/form.md index 09fc055486..5f5be5414a 100644 --- a/src/form/form.md +++ b/src/form/form.md @@ -12,6 +12,12 @@ {{ form-list }} +### 字段联动的表单 + +在某些特定场景,例如修改某个字段值后出现新的字段选项、或者纯粹希望表单任意变化都对某一个区域进行渲染。你可以通过 `shouldUpdate` 修改 `FormItem` 的更新逻辑。 + +{{ form-field-linkage }} + ### 自定义表单控件 可以使用 `Form.FormItem` 包裹自定义组件并在组件中接受 `value` 和 `onChange` 的入参,实现自定义表单控件。 @@ -76,26 +82,6 @@ Form 组件设计的初衷是为了解放开发者配置大量的 `value`、`onC ``` -### 如何根据某个字段变化动态展示数据 - -而在某些特定场景,例如修改某个字段值后出现新的字段选项、或者纯粹希望表单任意变化都对某一个区域进行渲染。你可以通过 `shouldUpdate` 修改 `FormItem` 的更新逻辑。 - -```js - prev.additional !== next.additional}> - {({ getFieldValue }) => { - if (getFieldValue('additional') === 'test') { - return ( - - - - ); - } - return null; - }} - - -``` - ## API ### Form Props diff --git a/src/form/hooks/useFormItemInitialData.ts b/src/form/hooks/useFormItemInitialData.ts new file mode 100644 index 0000000000..2e8908314c --- /dev/null +++ b/src/form/hooks/useFormItemInitialData.ts @@ -0,0 +1,96 @@ +import React, { useEffect } from 'react'; +import get from 'lodash/get'; +import unset from 'lodash/unset'; +import isEmpty from 'lodash/isEmpty'; + +// 兼容特殊数据结构和受控 key +import Tree from '../../tree/Tree'; +import Upload from '../../upload/upload'; +import CheckTag from '../../tag/CheckTag'; +import Checkbox from '../../checkbox/Checkbox'; +import TagInput from '../../tag-input/TagInput'; +import RangeInput from '../../range-input/RangeInput'; +import Transfer from '../../transfer/Transfer'; +import CheckboxGroup from '../../checkbox/CheckboxGroup'; +import DateRangePicker from '../../date-picker/DateRangePicker'; +import TimeRangePicker from '../../time-picker/TimeRangePicker'; + +import { useFormContext, useFormListContext } from '../FormContext'; +import { FormItemProps } from '../FormItem'; + +// FormItem 子组件受控 key +export const ctrlKeyMap = new Map(); +ctrlKeyMap.set(Checkbox, 'checked'); +ctrlKeyMap.set(CheckTag, 'checked'); +ctrlKeyMap.set(Upload, 'files'); + +// FormItem 默认数据类型 +export const initialDataMap = new Map(); +[Tree, Upload, Transfer, TagInput, RangeInput, CheckboxGroup, DateRangePicker, TimeRangePicker].forEach((component) => { + initialDataMap.set(component, []); +}); +[Checkbox].forEach((component) => { + initialDataMap.set(component, false); +}); + +export default function useFormItemInitialData(name: FormItemProps['name']) { + let hadReadFloatingFormData = false; + + const { floatingFormDataRef, initialData: formContextInitialData } = useFormContext(); + + const { name: formListName, initialData: formListInitialData } = useFormListContext(); + + // 组件渲染后删除对应游离值 + useEffect(() => { + if (hadReadFloatingFormData) { + const nameList = formListName ? [formListName, name].flat() : name; + unset(floatingFormDataRef.current, nameList); + } + }, [hadReadFloatingFormData, floatingFormDataRef, formListName, name]); + + // 整理初始值 优先级:Form.initialData < FormList.initialData < FormItem.initialData < floatFormData + function getDefaultInitialData({ + children, + initialData, + }: { + children: FormItemProps['children']; + initialData: FormItemProps['initialData']; + }) { + if (name && !isEmpty(floatingFormDataRef.current)) { + const nameList = formListName ? [formListName, name].flat() : name; + const defaultInitialData = get(floatingFormDataRef.current, nameList); + if (typeof defaultInitialData !== 'undefined') { + hadReadFloatingFormData = true; + return defaultInitialData; + } + } + + if (typeof initialData !== 'undefined') { + return initialData; + } + + if (name && formListInitialData.length) { + const defaultInitialData = get(formListInitialData, name); + if (typeof defaultInitialData !== 'undefined') return defaultInitialData; + } + + if (name && formContextInitialData) { + const defaultInitialData = get(formContextInitialData, name); + if (typeof defaultInitialData !== 'undefined') return defaultInitialData; + } + + if (typeof children !== 'function') { + const childList = React.Children.toArray(children); + const lastChild = childList[childList.length - 1]; + if (lastChild && React.isValidElement(lastChild)) { + // @ts-ignore + const isMultiple = lastChild?.props?.multiple; + return isMultiple ? [] : initialDataMap.get(lastChild.type); + } + } + } + + return { + getDefaultInitialData, + }; +} diff --git a/src/form/hooks/useInstance.tsx b/src/form/hooks/useInstance.tsx index 48accb0582..8a22f34802 100644 --- a/src/form/hooks/useInstance.tsx +++ b/src/form/hooks/useInstance.tsx @@ -1,6 +1,8 @@ import isEmpty from 'lodash/isEmpty'; import isFunction from 'lodash/isFunction'; import merge from 'lodash/merge'; +import get from 'lodash/get'; +import set from 'lodash/set'; import type { TdFormProps, FormValidateResult, @@ -10,7 +12,7 @@ import type { NamePath, } from '../type'; import useConfig from '../../hooks/useConfig'; -import { getMapValue, travelMapFromObject, calcFieldValue } from '../utils'; +import { getMapValue, objectToArray, travelMapFromObject, calcFieldValue } from '../utils'; import log from '../../_common/js/log'; // 检测是否需要校验 默认全量校验 @@ -40,7 +42,12 @@ function formatValidateResult(validateResultList) { return isEmpty(result) ? true : result; } -export default function useInstance(props: TdFormProps, formRef, formMapRef: React.MutableRefObject>) { +export default function useInstance( + props: TdFormProps, + formRef, + formMapRef: React.MutableRefObject>, + floatingFormDataRef: React.RefObject>, +) { const { classPrefix } = useConfig(); const { scrollToFirstError, preventSubmitDefault = true, onSubmit, onReset } = props; @@ -144,8 +151,16 @@ export default function useInstance(props: TdFormProps, formRef, formMapRef: Rea // 对外方法,设置对应 formItem 的值 function setFieldsValue(fields = {}) { - travelMapFromObject(fields, formMapRef, (formItemRef, fieldValue) => { - formItemRef?.current?.setValue?.(fieldValue, fields); + const nameLists = objectToArray(fields); + + nameLists.forEach((nameList) => { + const fieldValue = get(fields, nameList); + const formItemRef = formMapRef.current.get(nameList.length > 1 ? nameList : nameList[0]); + if (formItemRef?.current) { + formItemRef?.current?.setValue?.(fieldValue, fields); + } else { + set(floatingFormDataRef.current, nameList, fieldValue); + } }); } diff --git a/src/form/useInitialData.ts b/src/form/useInitialData.ts deleted file mode 100644 index 5fb821a7ca..0000000000 --- a/src/form/useInitialData.ts +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import get from 'lodash/get'; - -// 兼容特殊数据结构和受控 key -import Tree from '../tree/Tree'; -import Upload from '../upload/upload'; -import CheckTag from '../tag/CheckTag'; -import Checkbox from '../checkbox/Checkbox'; -import TagInput from '../tag-input/TagInput'; -import RangeInput from '../range-input/RangeInput'; -import Transfer from '../transfer/Transfer'; -import CheckboxGroup from '../checkbox/CheckboxGroup'; -import DateRangePicker from '../date-picker/DateRangePicker'; -import TimeRangePicker from '../time-picker/TimeRangePicker'; - -// FormItem 子组件受控 key -export const ctrlKeyMap = new Map(); -ctrlKeyMap.set(Checkbox, 'checked'); -ctrlKeyMap.set(CheckTag, 'checked'); -ctrlKeyMap.set(Upload, 'files'); - -// FormItem 默认数据类型 -export const initialDataMap = new Map(); -[Tree, Upload, Transfer, TagInput, RangeInput, CheckboxGroup, DateRangePicker, TimeRangePicker].forEach((component) => { - initialDataMap.set(component, []); -}); -[Checkbox].forEach((component) => { - initialDataMap.set(component, false); -}); - -// 整理初始值 优先级:Form.initialData < FormList.initialData < FormItem.initialData -export function getDefaultInitialData({ - name, - formListName, - children, - initialData, - FromContextInitialData, - FormListInitialData, -}) { - let defaultInitialData; - if (FromContextInitialData) { - if (typeof name === 'string') defaultInitialData = get(FromContextInitialData, name); - if (Array.isArray(name)) { - const nameList = formListName ? [formListName, ...name] : name; - defaultInitialData = get(FromContextInitialData, nameList); - } - } - if (FormListInitialData.length) { - defaultInitialData = get(FormListInitialData, name); - } - if (typeof initialData !== 'undefined') defaultInitialData = initialData; - React.Children.forEach(children, (child) => { - if (child && React.isValidElement(child) && typeof defaultInitialData === 'undefined') { - // @ts-ignore - const isMultiple = child?.props?.multiple; - defaultInitialData = isMultiple ? [] : initialDataMap.get(child.type); - } - }); - return defaultInitialData; -} diff --git a/src/form/utils/index.ts b/src/form/utils/index.ts index 1ff97b890c..fbb94f7428 100644 --- a/src/form/utils/index.ts +++ b/src/form/utils/index.ts @@ -1,3 +1,8 @@ +import has from 'lodash/has'; +import get from 'lodash/get'; +import isObject from 'lodash/isObject'; +import isArray from 'lodash/isArray'; +import isEmpty from 'lodash/isEmpty'; import type { NamePath } from '../type'; // 获取 formMap 管理的数据 @@ -12,6 +17,26 @@ export function getMapValue(name: NamePath, formMapRef: React.MutableRefObject [['user', 'name']] +// 不处理数组类型 +// { user: [{ name: '' }]} => [['user']] +export function objectToArray(obj: Record) { + const result: (string | number)[][] = []; + + function traverse(current: any, path: string[] = []) { + if (isObject(current) && !isArray(current) && !isEmpty(current)) { + Object.keys(current).forEach((key) => { + traverse(current[key], [...path, key]); + }); + } else { + result.push(path); + } + } + + traverse(obj); + return result; +} + // 将数据整理成对象,数组 name 转化嵌套对象: ['user', 'name'] => { user: { name: '' } } export function calcFieldValue(name: NamePath, value: any, isFormList = true) { if (Array.isArray(name)) { @@ -41,29 +66,8 @@ export function travelMapFromObject( callback: Function, ) { for (const [mapName, formItemRef] of formMapRef.current.entries()) { - // 支持嵌套数据结构 - if (Array.isArray(mapName)) { - // 创建唯一临时变量 symbol - const symbol = Symbol('name'); - let fieldValue = null; - - for (let i = 0; i < mapName.length; i++) { - const item = mapName[i]; - if (Reflect.has(fieldValue || obj, item)) { - fieldValue = Reflect.get(fieldValue || obj, item); - } else { - // 当反射无法获取到值则重置为 symbol - fieldValue = symbol; - break; - } - } - - // 非 symbol 说明获取到了值 - if (fieldValue !== symbol) { - callback(formItemRef, fieldValue); - } - } else if (Reflect.has(obj, mapName)) { - callback(formItemRef, obj[mapName]); + if (has(obj, mapName)) { + callback(formItemRef, get(obj, mapName)); } } } diff --git a/test/snap/__snapshots__/csr.test.jsx.snap b/test/snap/__snapshots__/csr.test.jsx.snap index e0422be7a8..ac50a00a77 100644 --- a/test/snap/__snapshots__/csr.test.jsx.snap +++ b/test/snap/__snapshots__/csr.test.jsx.snap @@ -44223,6 +44223,105 @@ exports[`csr snapshot test > csr test src/form/_example/error-message.tsx 1`] = `; +exports[`csr snapshot test > csr test src/form/_example/form-field-linkage.tsx 1`] = ` +
+
+
+
+ + : +
+
+
+
+ + +
+
+
+
+
+
+
+ +
+
+
+
+
+`; + exports[`csr snapshot test > csr test src/form/_example/form-list.tsx 1`] = `
ssr test src/form/_example/disabled.tsx 1`] = `" ssr test src/form/_example/error-message.tsx 1`] = `"
一句话介绍自己
"`; +exports[`ssr snapshot test > ssr test src/form/_example/form-field-linkage.tsx 1`] = `"
:
"`; + exports[`ssr snapshot test > ssr test src/form/_example/form-list.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test src/form/_example/layout.tsx 1`] = `"
"`; diff --git a/test/snap/__snapshots__/ssr.test.jsx.snap b/test/snap/__snapshots__/ssr.test.jsx.snap index bf22b6d3f0..152daaa315 100644 --- a/test/snap/__snapshots__/ssr.test.jsx.snap +++ b/test/snap/__snapshots__/ssr.test.jsx.snap @@ -412,6 +412,8 @@ exports[`ssr snapshot test > ssr test src/form/_example/disabled.tsx 1`] = `" ssr test src/form/_example/error-message.tsx 1`] = `"
一句话介绍自己
"`; +exports[`ssr snapshot test > ssr test src/form/_example/form-field-linkage.tsx 1`] = `"
:
"`; + exports[`ssr snapshot test > ssr test src/form/_example/form-list.tsx 1`] = `"
"`; exports[`ssr snapshot test > ssr test src/form/_example/layout.tsx 1`] = `"
"`;