mirror of
https://github.com/ant-design/ant-design.git
synced 2024-11-27 20:49:53 +08:00
Merge pull request #48677 from ant-design/master
chore: merge master into feature
This commit is contained in:
commit
ba5f9fe2f8
@ -211,10 +211,12 @@ const Overview: React.FC = () => {
|
||||
<Link to={url}>
|
||||
<Card
|
||||
onClick={() => onClickCard(url)}
|
||||
bodyStyle={{
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'bottom right',
|
||||
backgroundImage: `url(${component?.tag || ''})`,
|
||||
styles={{
|
||||
body: {
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'bottom right',
|
||||
backgroundImage: `url(${component?.tag || ''})`,
|
||||
},
|
||||
}}
|
||||
size="small"
|
||||
className={styles.componentsOverviewCard}
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import AutoComplete from '..';
|
||||
import { render } from '../../../tests/utils';
|
||||
import { act, render } from '../../../tests/utils';
|
||||
|
||||
describe('AutoComplete children could be focus', () => {
|
||||
beforeAll(() => {
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { act, fireEvent, render } from '@testing-library/react';
|
||||
|
||||
import type { GetRef } from '../../_util/type';
|
||||
import mountTest from '../../../tests/shared/mountTest';
|
||||
|
@ -1,7 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { fireEvent, render } from '../../../tests/utils';
|
||||
import { act, fireEvent, render } from '../../../tests/utils';
|
||||
import Button from '../button';
|
||||
|
||||
const specialDelay = 9529;
|
||||
|
@ -1,13 +1,12 @@
|
||||
import React, { Suspense, useRef, useState } from 'react';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { resetWarned } from 'rc-util/lib/warning';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import Button from '..';
|
||||
import type { GetRef } from '../../_util/type';
|
||||
import mountTest from '../../../tests/shared/mountTest';
|
||||
import rtlTest from '../../../tests/shared/rtlTest';
|
||||
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import { act, fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import ConfigProvider from '../../config-provider';
|
||||
import type { BaseButtonProps } from '../button';
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { resetWarned } from '../../_util/warning';
|
||||
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import { act, fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
|
||||
describe('Collapse', () => {
|
||||
// eslint-disable-next-line global-require
|
||||
@ -157,7 +156,7 @@ describe('Collapse', () => {
|
||||
jest.useFakeTimers();
|
||||
const spiedRAF = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((cb) => setTimeout(cb, 16.66));
|
||||
.mockImplementation((cb) => setTimeout(cb, 1000 / 60));
|
||||
|
||||
let setActiveKeyOuter: React.Dispatch<React.SetStateAction<React.Key | undefined>>;
|
||||
const Test: React.FC = () => {
|
||||
|
@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { ValidateMessages } from 'rc-field-form/es/interface';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import scrollIntoView from 'scroll-into-view-if-needed';
|
||||
|
||||
import ConfigProvider from '..';
|
||||
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import { act, fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import Button from '../../button';
|
||||
import type { FormInstance } from '../../form';
|
||||
import Form from '../../form';
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import type { DrawerProps } from '..';
|
||||
import Drawer from '..';
|
||||
import { resetWarned } from '../../_util/warning';
|
||||
import mountTest from '../../../tests/shared/mountTest';
|
||||
import rtlTest from '../../../tests/shared/rtlTest';
|
||||
import { fireEvent, render } from '../../../tests/utils';
|
||||
import { act, fireEvent, render } from '../../../tests/utils';
|
||||
import ConfigProvider from '../../config-provider';
|
||||
|
||||
const DrawerTest: React.FC<DrawerProps> = ({ getContainer }) => (
|
||||
|
@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import Form from '..';
|
||||
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import { act, fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import Input from '../../input';
|
||||
import type { FormListOperation } from '../FormList';
|
||||
|
||||
|
@ -1,10 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { Col, Row } from '..';
|
||||
import mountTest from '../../../tests/shared/mountTest';
|
||||
import rtlTest from '../../../tests/shared/rtlTest';
|
||||
import { fireEvent, render } from '../../../tests/utils';
|
||||
import { act, fireEvent, render } from '../../../tests/utils';
|
||||
import useBreakpoint from '../hooks/useBreakpoint';
|
||||
|
||||
// Mock for `responsiveObserve` to test `unsubscribe` call
|
||||
|
@ -77,6 +77,9 @@ const genOutlinedStatusStyle = (
|
||||
color: options.affixColor,
|
||||
},
|
||||
},
|
||||
[`&${token.componentCls}-status-${options.status}${token.componentCls}-disabled`]: {
|
||||
borderColor: options.borderColor,
|
||||
},
|
||||
});
|
||||
|
||||
export const genOutlinedStyle = (token: InputToken, extraStyles?: CSSObject): CSSObject => ({
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import Layout from '..';
|
||||
import mountTest from '../../../tests/shared/mountTest';
|
||||
import rtlTest from '../../../tests/shared/rtlTest';
|
||||
import { fireEvent, render } from '../../../tests/utils';
|
||||
import { act, fireEvent, render } from '../../../tests/utils';
|
||||
import Menu from '../../menu';
|
||||
|
||||
const { Sider, Content, Footer, Header } = Layout;
|
||||
|
@ -1,10 +1,9 @@
|
||||
/* eslint-disable jsx-a11y/control-has-associated-label */
|
||||
import React, { useEffect } from 'react';
|
||||
import { createCache, extractStyle, StyleProvider } from '@ant-design/cssinjs';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import message from '..';
|
||||
import { fireEvent, render } from '../../../tests/utils';
|
||||
import { act, fireEvent, render } from '../../../tests/utils';
|
||||
import ConfigProvider from '../../config-provider';
|
||||
import { triggerMotionEnd } from './util';
|
||||
|
||||
|
@ -4,11 +4,10 @@ import CSSMotion from 'rc-motion';
|
||||
import { genCSSMotion } from 'rc-motion/lib/CSSMotion';
|
||||
import KeyCode from 'rc-util/lib/KeyCode';
|
||||
import { resetWarned } from 'rc-util/lib/warning';
|
||||
import TestUtils from 'react-dom/test-utils';
|
||||
|
||||
import type { ModalFuncProps } from '..';
|
||||
import Modal from '..';
|
||||
import { act, waitFakeTimer } from '../../../tests/utils';
|
||||
import { act, fireEvent, waitFakeTimer } from '../../../tests/utils';
|
||||
import ConfigProvider from '../../config-provider';
|
||||
import type { ModalFunc } from '../confirm';
|
||||
import destroyFns from '../destroyFns';
|
||||
@ -191,9 +190,7 @@ describe('Modal.confirm triggers callbacks correctly', () => {
|
||||
await waitFakeTimer();
|
||||
|
||||
expect($$(`.ant-modal-confirm-confirm`)).toHaveLength(1);
|
||||
TestUtils.Simulate.keyDown($$('.ant-modal')[0], {
|
||||
keyCode: KeyCode.ESC,
|
||||
});
|
||||
fireEvent.keyDown($$('.ant-modal')[0], { keyCode: KeyCode.ESC });
|
||||
|
||||
await waitFakeTimer(0);
|
||||
|
||||
@ -706,9 +703,7 @@ describe('Modal.confirm triggers callbacks correctly', () => {
|
||||
await waitFakeTimer();
|
||||
|
||||
expect($$(`.ant-modal-confirm-${type}`)).toHaveLength(1);
|
||||
TestUtils.Simulate.keyDown($$('.ant-modal')[0], {
|
||||
keyCode: KeyCode.ESC,
|
||||
});
|
||||
fireEvent.keyDown($$('.ant-modal')[0], { keyCode: KeyCode.ESC });
|
||||
|
||||
await waitFakeTimer(0);
|
||||
|
||||
|
@ -2,10 +2,9 @@ import React from 'react';
|
||||
import CSSMotion from 'rc-motion';
|
||||
import { genCSSMotion } from 'rc-motion/lib/CSSMotion';
|
||||
import KeyCode from 'rc-util/lib/KeyCode';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import Modal from '..';
|
||||
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import { act, fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import Button from '../../button';
|
||||
import ConfigProvider from '../../config-provider';
|
||||
import Input from '../../input';
|
||||
|
@ -18,7 +18,7 @@ const genStepsCustomIconStyle: GenerateStyle<StepsToken, CSSObject> = (token) =>
|
||||
width: customIconSize,
|
||||
height: customIconSize,
|
||||
fontSize: customIconFontSize,
|
||||
lineHeight: `${unit(customIconFontSize)}`,
|
||||
lineHeight: `${unit(customIconSize)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { Simulate } from 'react-dom/test-utils';
|
||||
|
||||
import Tag from '..';
|
||||
import { resetWarned } from '../../_util/warning';
|
||||
@ -89,40 +88,20 @@ describe('Tag', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should trigger onClick', () => {
|
||||
it('should trigger onClick on Tag', () => {
|
||||
const onClick = jest.fn();
|
||||
const { container } = render(<Tag onClick={onClick} />);
|
||||
const target = container.querySelectorAll('.ant-tag')[0];
|
||||
Simulate.click(target);
|
||||
expect(onClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'click',
|
||||
target,
|
||||
preventDefault: expect.any(Function),
|
||||
nativeEvent: {
|
||||
type: 'click',
|
||||
target,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const tagElement = container.querySelector<HTMLSpanElement>('.ant-tag')!;
|
||||
fireEvent.click(tagElement);
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should trigger onClick on CheckableTag', () => {
|
||||
it('should trigger onClick on Tag.CheckableTag', () => {
|
||||
const onClick = jest.fn();
|
||||
const { container } = render(<Tag.CheckableTag checked={false} onClick={onClick} />);
|
||||
const target = container.querySelectorAll('.ant-tag')[0];
|
||||
Simulate.click(target);
|
||||
expect(onClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'click',
|
||||
target,
|
||||
preventDefault: expect.any(Function),
|
||||
nativeEvent: {
|
||||
type: 'click',
|
||||
target,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const tagElement = container.querySelector<HTMLSpanElement>('.ant-tag')!;
|
||||
fireEvent.click(tagElement);
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// https://github.com/ant-design/ant-design/issues/20344
|
||||
|
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { spyElementPrototype } from 'rc-util/lib/test/domHook';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import type { TooltipPlacement } from '..';
|
||||
import Tooltip from '..';
|
||||
@ -8,7 +7,7 @@ import getPlacements from '../../_util/placements';
|
||||
import { resetWarned } from '../../_util/warning';
|
||||
import mountTest from '../../../tests/shared/mountTest';
|
||||
import rtlTest from '../../../tests/shared/rtlTest';
|
||||
import { fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import { act, fireEvent, render, waitFakeTimer } from '../../../tests/utils';
|
||||
import Button from '../../button';
|
||||
import DatePicker from '../../date-picker';
|
||||
import Input from '../../input';
|
||||
|
@ -1,33 +1,16 @@
|
||||
/* eslint no-use-before-define: "off" */
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import Transfer from '..';
|
||||
import { fireEvent, render } from '../../../tests/utils';
|
||||
import { act, fireEvent, render } from '../../../tests/utils';
|
||||
|
||||
const listProps = {
|
||||
dataSource: [
|
||||
{
|
||||
key: 'a',
|
||||
title: 'a',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
title: 'b',
|
||||
},
|
||||
{
|
||||
key: 'c',
|
||||
title: 'c',
|
||||
},
|
||||
{
|
||||
key: 'd',
|
||||
title: 'd',
|
||||
},
|
||||
{
|
||||
key: 'e',
|
||||
title: 'e',
|
||||
},
|
||||
{ key: 'a', title: 'a', disabled: true },
|
||||
{ key: 'b', title: 'b' },
|
||||
{ key: 'c', title: 'c' },
|
||||
{ key: 'd', title: 'd' },
|
||||
{ key: 'e', title: 'e' },
|
||||
],
|
||||
selectedKeys: ['b'],
|
||||
targetKeys: [],
|
||||
|
@ -9,21 +9,9 @@ describe('Transfer.Search', () => {
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const dataSource = [
|
||||
{
|
||||
key: 'a',
|
||||
title: 'a',
|
||||
description: 'a',
|
||||
},
|
||||
{
|
||||
key: 'b',
|
||||
title: 'b',
|
||||
description: 'b',
|
||||
},
|
||||
{
|
||||
key: 'c',
|
||||
title: 'c',
|
||||
description: 'c',
|
||||
},
|
||||
{ key: 'a', title: 'a', description: 'a' },
|
||||
{ key: 'b', title: 'b', description: 'b' },
|
||||
{ key: 'c', title: 'c', description: 'c' },
|
||||
];
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -343,7 +343,7 @@ const Transfer = <RecordType extends TransferItem = TransferItem>(
|
||||
const holder = [...(isLeftDirection ? sourceSelectedKeys : targetSelectedKeys)];
|
||||
const holderSet = new Set(holder);
|
||||
const data = [...(isLeftDirection ? leftDataSource : rightDataSource)].filter(
|
||||
(item) => !item.disabled,
|
||||
(item) => !item?.disabled,
|
||||
);
|
||||
const currentSelectedIndex = data.findIndex((item) => item.key === selectedKey);
|
||||
// multiple select by hold down the shift key
|
||||
|
@ -1,8 +1,14 @@
|
||||
import React from 'react';
|
||||
import { spyElementPrototypes } from 'rc-util/lib/test/domHook';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { fireEvent, render, triggerResize, waitFakeTimer, waitFor } from '../../../tests/utils';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
triggerResize,
|
||||
waitFakeTimer,
|
||||
waitFor,
|
||||
} from '../../../tests/utils';
|
||||
import type { EllipsisConfig } from '../Base';
|
||||
import Base from '../Base';
|
||||
|
||||
|
147
docs/blog/form-names.en-US.md
Normal file
147
docs/blog/form-names.en-US.md
Normal file
@ -0,0 +1,147 @@
|
||||
---
|
||||
title: HOC Aggregate FieldItem
|
||||
date: 2024-04-26
|
||||
author: crazyair
|
||||
---
|
||||
|
||||
During the form development process, there are occasional needs for combining attributes. The UI display fields are different from the backend data structure fields. For example, when interfacing with the backend, the province and city fields are often defined as two separate fields `{ province: Beijing, city: Haidian }`, rather than a combined one `{ province: [Beijing, Haidian] }`. Therefore, it is necessary to handle the values in `initialValues` and `onFinish` as follows:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Cascader, Form } from 'antd';
|
||||
|
||||
const data = { province: 'Beijing', city: 'Haidian' };
|
||||
const options = [
|
||||
{ value: 'zhejiang', label: 'Zhejiang', children: [{ value: 'hangzhou', label: 'Hangzhou' }] },
|
||||
{ value: 'jiangsu', label: 'Jiangsu', children: [{ value: 'nanjing', label: 'Nanjing' }] },
|
||||
];
|
||||
const createUser = (values) => console.log(values);
|
||||
|
||||
const Demo = () => (
|
||||
<Form
|
||||
initialValues={{ province: [data.province, data.city] }}
|
||||
onFinish={(values) => {
|
||||
const { province, ...rest } = values;
|
||||
createUser({ province: province[0], city: province[1], ...rest });
|
||||
}}
|
||||
>
|
||||
<Form.Item label="Address" name="province">
|
||||
<Cascader options={options} placeholder="Please select" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
export default Demo;
|
||||
```
|
||||
|
||||
## Encapsulating Aggregate Field Components
|
||||
|
||||
When the form is relatively simple, it's manageable, but when encountering a `Form.List` scenario, it becomes necessary to process the values using `map`, which can become quite complex. Therefore, we need to encapsulate an aggregated field component to enable a single `Form.Item` to handle multiple `name` attributes.
|
||||
|
||||
## Approach Summary
|
||||
|
||||
To implement the aggregation field functionality, we need to utilize `getValueProps`, `getValueFromEvent`, and `transform` to facilitate the transformation of data from `FormStore` and to re-insert the structure into `FormStore` upon change.
|
||||
|
||||
### getValueProps
|
||||
|
||||
By default, `Form.Item` passes the field value from `FormStore` as the `value` prop to the child component. However, with `getValueProps`, you can customize the `props` that are passed to the child component to implement transformation functionality. In an aggregation scenario, we can iterate through `names` and combine the values from `FormStore` into a single `value` that is then passed to the child component:
|
||||
|
||||
```tsx
|
||||
getValueProps={() => ({ value: names.map((name) => form.getFieldValue(name)) })}
|
||||
```
|
||||
|
||||
### getValueFromEvent
|
||||
|
||||
When the child component modifies the value, the `setFields` method is used to set the aggregated `value` returned by the child component to the corresponding `name`, thereby updating the values of `names` in `FormStore`:
|
||||
|
||||
```tsx
|
||||
getValueFromEvent={(values) => {
|
||||
form.setFields(names.map((name, index) => ({ name, value: values[index] })));
|
||||
return values[0];
|
||||
}}
|
||||
```
|
||||
|
||||
### transform
|
||||
|
||||
In `rules`, the default provided `value` for validation originates from the value passed to the corresponding `name` when the child component changes. Additionally, it is necessary to retrieve the values of `names` from `FormStore` and use the `transform` method to modify the `value` of `rules`:
|
||||
|
||||
```tsx
|
||||
rules={[{
|
||||
transform: () => {
|
||||
const values = names.map((name) => form.getFieldValue(name));
|
||||
return values;
|
||||
},
|
||||
}]}
|
||||
```
|
||||
|
||||
## Final Result
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import type { FormItemProps } from 'antd';
|
||||
import { Cascader, Form } from 'antd';
|
||||
|
||||
export const AggregateFormItem = (
|
||||
props: FormItemProps & { names?: FormItemProps<Record<string, any>>['name'][] },
|
||||
) => {
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const { names = [], rules = [], ...rest } = props;
|
||||
const [firstName, ...resetNames] = names;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name={firstName}
|
||||
// Convert the values of names into an array passed to children
|
||||
getValueProps={() => ({ value: names.map((name) => form.getFieldValue(name)) })}
|
||||
getValueFromEvent={(values) => {
|
||||
// Set the form store values for names
|
||||
form.setFields(names.map((name, index) => ({ name, value: values[index] })));
|
||||
return values[0];
|
||||
}}
|
||||
rules={rules.map((thisRule) => {
|
||||
if (typeof thisRule === 'object') {
|
||||
return {
|
||||
...thisRule,
|
||||
transform: () => {
|
||||
// Set the values of the names fields for the rule value
|
||||
const values = names.map((name) => form.getFieldValue(name));
|
||||
return values;
|
||||
},
|
||||
};
|
||||
}
|
||||
return thisRule;
|
||||
})}
|
||||
{...rest}
|
||||
/>
|
||||
{/* Bind other fields so they can getFieldValue to get values and setFields to set values */}
|
||||
{resetNames.map((name) => (
|
||||
<Form.Item key={name?.toString()} name={name} noStyle />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const data = { province: 'Beijing', city: 'Haidian' };
|
||||
const options = [
|
||||
{ value: 'zhejiang', label: 'Zhejiang', children: [{ value: 'hangzhou', label: 'Hangzhou' }] },
|
||||
{ value: 'jiangsu', label: 'Jiangsu', children: [{ value: 'nanjing', label: 'Nanjing' }] },
|
||||
];
|
||||
const createUser = (values) => console.log(values);
|
||||
|
||||
export const Demo = () => (
|
||||
<Form
|
||||
initialValues={data}
|
||||
onFinish={(values) => {
|
||||
createUser(values);
|
||||
}}
|
||||
>
|
||||
<AggregateFormItem label="Address" names={['province', 'city']} rules={[{ required: true }]}>
|
||||
<Cascader options={options} placeholder="Please select" />
|
||||
</AggregateFormItem>
|
||||
</Form>
|
||||
);
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
By doing so, we have implemented a feature that allows for operating multiple `names` within a `Form.Item`, making the form logic clearer and easier to maintain. Additionally, there are some edge cases in this example that have not been considered. For instance, `setFields([{ name:'city', value:'nanjing' }])` will not update the selected value of `Cascader`. To achieve a refresh effect, you need to add `Form.useWatch(values => resetNames.map(name => get(values, name)), form);`. Feel free to explore more edge cases and handle them as needed.
|
147
docs/blog/form-names.zh-CN.md
Normal file
147
docs/blog/form-names.zh-CN.md
Normal file
@ -0,0 +1,147 @@
|
||||
---
|
||||
title: 封装 Form.Item 实现数组转对象
|
||||
date: 2024-04-26
|
||||
author: crazyair
|
||||
---
|
||||
|
||||
在表单开发过程中,偶尔会遇到组合属性的需求。UI 展示字段与后端返回数据结构字段有所不同。比如说,跟后端对接接口,定义省市字段经常是 2 个字段 `{ province: Beijing, city: Haidian }`,而不是 `{ province:[Beijing,Haidian] }`,因此则需要在 `initialValues` 以及 `onFinish` 处理值,如下:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Cascader, Form } from 'antd';
|
||||
|
||||
const data = { province: 'Beijing', city: 'Haidian' };
|
||||
const options = [
|
||||
{ value: 'zhejiang', label: 'Zhejiang', children: [{ value: 'hangzhou', label: 'Hangzhou' }] },
|
||||
{ value: 'jiangsu', label: 'Jiangsu', children: [{ value: 'nanjing', label: 'Nanjing' }] },
|
||||
];
|
||||
const createUser = (values) => console.log(values);
|
||||
|
||||
const Demo = () => (
|
||||
<Form
|
||||
initialValues={{ province: [data.province, data.city] }}
|
||||
onFinish={(values) => {
|
||||
const { province, ...rest } = values;
|
||||
createUser({ province: province[0], city: province[1], ...rest });
|
||||
}}
|
||||
>
|
||||
<Form.Item label="Address" name="province">
|
||||
<Cascader options={options} placeholder="Please select" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
export default Demo;
|
||||
```
|
||||
|
||||
## 封装聚合字段组件
|
||||
|
||||
当表单比较简单还好,如果遇到 `Form.List` 场景,就需要 `map` 处理值,将变的很复杂。于是我们需要封装聚合字段组件,实现一个 `Form.Item` 可以写多个 `name`。
|
||||
|
||||
## 思路整理
|
||||
|
||||
要实现聚合字段功能,我们需要用到 `getValueProps` `getValueFromEvent` `transform`,从而实现数据从 `FormStore` 中的转化,以及变更时重新传入 `FormStore` 结构中。
|
||||
|
||||
### getValueProps
|
||||
|
||||
默认情况下,`Form.Item` 会将 `FormStore` 中的字段值作为 `value` prop 传递给子组件。而通过 `getValueProps` 可以自定义传入给子组件的 `props` 从而实现转化功能。在聚合场景中,我们可以遍历 `names` 分别将 `FormStore` 中的值组合为一个 `value` 传递给子组件:
|
||||
|
||||
```tsx
|
||||
getValueProps={() => ({ value: names.map((name) => form.getFieldValue(name)) })}
|
||||
```
|
||||
|
||||
### getValueFromEvent
|
||||
|
||||
当子组件修改值时,使用 `setFields` 方法将子组件返回的聚合 `value` 分别设置给对应的 `name`,从而实现更新 `FormStore` 中 `names` 的值:
|
||||
|
||||
```tsx
|
||||
getValueFromEvent={(values) => {
|
||||
form.setFields(names.map((name, index) => ({ name, value: values[index] })));
|
||||
return values[0];
|
||||
}}
|
||||
```
|
||||
|
||||
### transform
|
||||
|
||||
`rules` 中校验默认提供的 `value` 来源于子组件变更时传递给 `name` 对应的值,还需要从 `FormStore` 获取 `names` 的值使用 `transform` 方法修改 `rules` 的 `value`:
|
||||
|
||||
```tsx
|
||||
rules={[{
|
||||
transform: () => {
|
||||
const values = names.map((name) => form.getFieldValue(name));
|
||||
return values;
|
||||
},
|
||||
}]}
|
||||
```
|
||||
|
||||
## 最终效果
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import type { FormItemProps } from 'antd';
|
||||
import { Cascader, Form } from 'antd';
|
||||
|
||||
export const AggregateFormItem = (
|
||||
props: FormItemProps & { names?: FormItemProps<Record<string, any>>['name'][] },
|
||||
) => {
|
||||
const form = Form.useFormInstance();
|
||||
|
||||
const { names = [], rules = [], ...rest } = props;
|
||||
const [firstName, ...resetNames] = names;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name={firstName}
|
||||
// 将 names 的值转成数组传给 children
|
||||
getValueProps={() => ({ value: names.map((name) => form.getFieldValue(name)) })}
|
||||
getValueFromEvent={(values) => {
|
||||
// 将 form store 分别设置给 names
|
||||
form.setFields(names.map((name, index) => ({ name, value: values[index] })));
|
||||
return values[0];
|
||||
}}
|
||||
rules={rules.map((thisRule) => {
|
||||
if (typeof thisRule === 'object') {
|
||||
return {
|
||||
...thisRule,
|
||||
transform: () => {
|
||||
// 将 names 字段的值设置给 rule value
|
||||
const values = names.map((name) => form.getFieldValue(name));
|
||||
return values;
|
||||
},
|
||||
};
|
||||
}
|
||||
return thisRule;
|
||||
})}
|
||||
{...rest}
|
||||
/>
|
||||
{/* 绑定其他字段,使其可以 getFieldValue 获取值、setFields 设置值 */}
|
||||
{resetNames.map((name) => (
|
||||
<Form.Item key={name?.toString()} name={name} noStyle />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const data = { province: 'Beijing', city: 'Haidian' };
|
||||
const options = [
|
||||
{ value: 'zhejiang', label: 'Zhejiang', children: [{ value: 'hangzhou', label: 'Hangzhou' }] },
|
||||
{ value: 'jiangsu', label: 'Jiangsu', children: [{ value: 'nanjing', label: 'Nanjing' }] },
|
||||
];
|
||||
const createUser = (values) => console.log(values);
|
||||
|
||||
export const Demo = () => (
|
||||
<Form
|
||||
initialValues={data}
|
||||
onFinish={(values) => {
|
||||
createUser(values);
|
||||
}}
|
||||
>
|
||||
<AggregateFormItem label="Address" names={['province', 'city']} rules={[{ required: true }]}>
|
||||
<Cascader options={options} placeholder="Please select" />
|
||||
</AggregateFormItem>
|
||||
</Form>
|
||||
);
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过这种方式,我们实现了一个可以在 `Form.Item` 中操作多个 `name` 的功能,使得表单逻辑更加清晰和易于维护。另外此示例还有些边界场景没有考虑,比如 `setFields([{ name:'city' value:'nanjing' }])` 不会更新 `Cascader` 选中的值,需要增加 `Form.useWatch(values => resetNames.map(name => get(values, name)), form);` 达到刷新效果等。更多的边界问题就交给你去试试吧~
|
16
package.json
16
package.json
@ -172,7 +172,7 @@
|
||||
"@antv/g6": "^4.8.24",
|
||||
"@babel/eslint-plugin": "^7.23.5",
|
||||
"@biomejs/biome": "^1.7.1",
|
||||
"@codesandbox/sandpack-react": "^2.13.8",
|
||||
"@codesandbox/sandpack-react": "^2.13.9",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@ -189,7 +189,7 @@
|
||||
"@stackblitz/sdk": "^1.9.0",
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@testing-library/jest-dom": "^6.4.2",
|
||||
"@testing-library/react": "^15.0.4",
|
||||
"@testing-library/react": "^15.0.5",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/adm-zip": "^0.5.5",
|
||||
"@types/ali-oss": "^6.16.11",
|
||||
@ -215,9 +215,9 @@
|
||||
"@types/prismjs": "^1.26.3",
|
||||
"@types/progress": "^2.0.7",
|
||||
"@types/qs": "^6.9.15",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-highlight-words": "^0.16.7",
|
||||
"@types/react-resizable": "^3.0.7",
|
||||
"@types/semver": "^7.5.8",
|
||||
@ -241,7 +241,7 @@
|
||||
"cross-fetch": "^4.0.0",
|
||||
"crypto": "^1.0.1",
|
||||
"dekko": "^0.2.1",
|
||||
"dumi": "^2.3.0-rc.0",
|
||||
"dumi": "^2.3.0",
|
||||
"dumi-plugin-color-chunk": "^1.1.0",
|
||||
"esbuild-loader": "^4.1.0",
|
||||
"eslint": "^8.57.0",
|
||||
@ -255,7 +255,7 @@
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-markdown": "^4.0.1",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.1",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fetch-jsonp": "^1.3.0",
|
||||
@ -303,10 +303,10 @@
|
||||
"rc-footer": "^0.6.8",
|
||||
"rc-tween-one": "^3.0.6",
|
||||
"rc-virtual-list": "^3.11.5",
|
||||
"react": "18.2.0",
|
||||
"react": "^18.3.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-countup": "^6.5.3",
|
||||
"react-dom": "18.2.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-fast-marquee": "^1.6.4",
|
||||
"react-highlight-words": "^0.20.0",
|
||||
|
@ -1,25 +1,19 @@
|
||||
/* eslint-disable no-console, import/prefer-default-export */
|
||||
import util from 'util';
|
||||
import React from 'react';
|
||||
import type { DOMWindow } from 'jsdom';
|
||||
|
||||
// import { fillWindowEnv } from './utils';
|
||||
|
||||
const React = require('react');
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Current React Version:', React.version);
|
||||
|
||||
const originConsoleErr = console.error;
|
||||
|
||||
const ignoreWarns = ['validateDOMNesting', 'on an unmounted component', 'not wrapped in act'];
|
||||
|
||||
// Hack off React warning to avoid too large log in CI.
|
||||
console.error = (...args) => {
|
||||
const str = args.join('').replace(/\n/g, '');
|
||||
|
||||
if (
|
||||
['validateDOMNesting', 'on an unmounted component', 'not wrapped in act'].every(
|
||||
(warn) => !str.includes(warn),
|
||||
)
|
||||
) {
|
||||
if (ignoreWarns.every((warn) => !str.includes(warn))) {
|
||||
originConsoleErr(...args);
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user