Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
3 changes: 3 additions & 0 deletions docs/demo/list-unmount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## list

<code src="../examples/list-unmount.tsx"></code>
56 changes: 56 additions & 0 deletions docs/examples/list-unmount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { useState } from 'react';
import Form from 'rc-field-form';
import Input from './components/Input';
import LabelField from './components/LabelField';

const Demo = () => {
const [form] = Form.useForm();
const [isShow, setIsShow] = useState(true);

return (
<div>
<Form
form={form}
onFinish={values => {
console.log(JSON.stringify(values, null, 2));
console.log(JSON.stringify(form.getFieldsValue({ strict: true }), null, 2));
}}
initialValues={{
users: [
{ name: 'a', age: '1' },
{ name: 'b', age: '2' },
],
}}
>
<Form.Field shouldUpdate>{() => JSON.stringify(form.getFieldsValue(), null, 2)}</Form.Field>

<Form.List name="users">
{fields => {
return (
<div>
{fields.map(field => (
<div key={field.key} style={{ display: 'flex', gap: 10 }}>
<LabelField name={[field.name, 'name']}>
<Input />
</LabelField>
{isShow && (
<LabelField name={[field.name, 'age']}>
<Input />
</LabelField>
)}
</div>
))}
</div>
);
}}
</Form.List>
<button type="button" onClick={() => setIsShow(c => !c)}>
隐藏
</button>
<button type="submit">Submit</button>
</Form>
</div>
);
};

export default Demo;
2 changes: 1 addition & 1 deletion src/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type { FormContextProps } from './FormContext';
import FormContext from './FormContext';
import { isSimilar } from './utils/valueUtil';
import ListContext from './ListContext';
import BatchUpdate, { BatchTask, type BatchUpdateRef } from './BatchUpdate';
import BatchUpdate, { type BatchTask, type BatchUpdateRef } from './BatchUpdate';

type BaseFormProps = Omit<React.FormHTMLAttributes<HTMLFormElement>, 'onSubmit' | 'children'>;

Expand Down
2 changes: 1 addition & 1 deletion src/List.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as React from 'react';
import warning from '@rc-component/util/lib/warning';
import type { InternalNamePath, NamePath, StoreValue, ValidatorRule, Meta } from './interface';
import FieldContext from './FieldContext';
import FieldContext, { HOOK_MARK } from './FieldContext';
import Field from './Field';
import { move, getNamePath } from './utils/valueUtil';
import type { ListContextProps } from './ListContext';
Expand Down
6 changes: 6 additions & 0 deletions src/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ export interface FieldEntity {
dependencies?: NamePath[];
initialValue?: any;
};

/**
* Mask as invalidate.
* This will filled when Field is removed but not updated in render yet.
*/
INVALIDATE_NAME_PATH?: InternalNamePath;
}

export interface FieldError {
Expand Down
57 changes: 37 additions & 20 deletions src/useForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
} from './utils/valueUtil';
import type { BatchTask } from './BatchUpdate';

type InvalidateFieldEntity = { INVALIDATE_NAME_PATH: InternalNamePath };
type FlexibleFieldEntity = Partial<FieldEntity>;

interface UpdateAction {
type: 'updateValue';
Expand Down Expand Up @@ -282,9 +282,7 @@ export class FormStore {
return cache;
};

private getFieldEntitiesForNamePathList = (
nameList?: NamePath[],
): (FieldEntity | InvalidateFieldEntity)[] => {
private getFieldEntitiesForNamePathList = (nameList?: NamePath[]): FlexibleFieldEntity[] => {
if (!nameList) {
return this.getFieldEntities(true);
}
Expand All @@ -304,13 +302,11 @@ export class FormStore {
// Fill args
let mergedNameList: NamePath[] | true;
let mergedFilterFunc: FilterFunc;
let mergedStrict: boolean;

if (nameList === true || Array.isArray(nameList)) {
mergedNameList = nameList;
mergedFilterFunc = filterFunc;
} else if (nameList && typeof nameList === 'object') {
mergedStrict = nameList.strict;
mergedFilterFunc = nameList.filter;
}

Expand All @@ -323,17 +319,15 @@ export class FormStore {
);

const filteredNameList: NamePath[] = [];
fieldEntities.forEach((entity: FieldEntity | InvalidateFieldEntity) => {
const namePath =
'INVALIDATE_NAME_PATH' in entity ? entity.INVALIDATE_NAME_PATH : entity.getNamePath();
const listNamePaths: InternalNamePath[] = [];

fieldEntities.forEach((entity: FlexibleFieldEntity) => {
const namePath = entity.INVALIDATE_NAME_PATH || entity.getNamePath();

// Ignore when it's a list item and not specific the namePath,
// since parent field is already take in count
if (mergedStrict) {
if ((entity as FieldEntity).isList?.()) {
return;
}
} else if (!mergedNameList && (entity as FieldEntity).isListField?.()) {
if ((entity as FieldEntity).isList?.()) {
listNamePaths.push(namePath);
return;
}

Expand All @@ -347,7 +341,16 @@ export class FormStore {
}
});

return cloneByNamePathList(this.store, filteredNameList.map(getNamePath));
let mergedValues = cloneByNamePathList(this.store, filteredNameList.map(getNamePath));

// We need fill the list as [] if Form.List is empty
listNamePaths.forEach(namePath => {
if (!getValue(mergedValues, namePath)) {
mergedValues = setValue(mergedValues, namePath, []);
}
});

return mergedValues;
};

private getFieldValue = (name: NamePath) => {
Expand All @@ -363,7 +366,7 @@ export class FormStore {
const fieldEntities = this.getFieldEntitiesForNamePathList(nameList);

return fieldEntities.map((entity, index) => {
if (entity && !('INVALIDATE_NAME_PATH' in entity)) {
if (entity && !entity.INVALIDATE_NAME_PATH) {
return {
name: entity.getNamePath(),
errors: entity.getErrors(),
Expand Down Expand Up @@ -781,7 +784,10 @@ export class FormStore {

if (onValuesChange) {
const changedValues = cloneByNamePathList(this.store, [namePath]);
onValuesChange(changedValues, this.getFieldsValue());
const allValues = this.getFieldsValue();
// Merge changedValues into allValues to ensure allValues contains the latest changes
const mergedAllValues = merge(allValues, changedValues);
onValuesChange(changedValues, mergedAllValues);
}

this.triggerOnFieldsChange([namePath, ...childrenFields]);
Expand Down Expand Up @@ -910,6 +916,8 @@ export class FormStore {
const namePathList: InternalNamePath[] | undefined = provideNameList
? nameList.map(getNamePath)
: [];
// Same namePathList, but does not include Form.List name
const finalValueNamePathList = [...namePathList];

// Collect result in promise list
const promiseList: Promise<FieldError>[] = [];
Expand All @@ -921,9 +929,19 @@ export class FormStore {
const { recursive, dirty } = options || {};

this.getFieldEntities(true).forEach((field: FieldEntity) => {
const fieldNamePath = field.getNamePath();

// Add field if not provide `nameList`
if (!provideNameList) {
namePathList.push(field.getNamePath());
if (
// If is field, pass directly
!field.isList() ||
// If is list, do not add if already exist sub field in the namePathList
!namePathList.some(name => matchNamePath(name, fieldNamePath, true))
) {
finalValueNamePathList.push(fieldNamePath);
}
namePathList.push(fieldNamePath);
}

// Skip if without rule
Expand All @@ -936,7 +954,6 @@ export class FormStore {
return;
}

const fieldNamePath = field.getNamePath();
validateNamePathList.add(fieldNamePath.join(TMP_SPLIT));

// Add field validate rule in to promise list
Expand Down Expand Up @@ -1000,7 +1017,7 @@ export class FormStore {
const returnPromise: Promise<Store | ValidateErrorEntity | string[]> = summaryPromise
.then((): Promise<Store | string[]> => {
if (this.lastValidatePromise === summaryPromise) {
return Promise.resolve(this.getFieldsValue(namePathList));
return Promise.resolve(this.getFieldsValue(finalValueNamePathList));
}
return Promise.reject<string[]>([]);
})
Expand Down
4 changes: 4 additions & 0 deletions src/utils/valueUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export function getNamePath(path: NamePath | null): InternalNamePath {
return toArray(path);
}

/**
* Create a new store object that contains only the values referenced by
* the provided list of name paths.
*/
export function cloneByNamePathList(store: Store, namePathList: InternalNamePath[]): Store {
let newStore = {};
namePathList.forEach(namePath => {
Expand Down
109 changes: 107 additions & 2 deletions tests/list.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { fireEvent, render, act } from '@testing-library/react';
import { resetWarned } from '@rc-component/util/lib/warning';
import Form, { Field, List } from '../src';
Expand Down Expand Up @@ -569,7 +569,7 @@ describe('Form.List', () => {
expect(currentMeta.errors).toEqual(['Bamboo Light']);
});

it('Nest list remove should trigger correct onValuesChange', () => {
it('Nest list remove index should trigger correct onValuesChange', () => {
const onValuesChange = jest.fn();

const [container] = generateForm(
Expand All @@ -596,6 +596,7 @@ describe('Form.List', () => {
},
);

onValuesChange.mockReset();
fireEvent.click(container.querySelector('button')!);
expect(onValuesChange).toHaveBeenCalledWith(expect.anything(), { list: [{ first: 'light' }] });
});
Expand Down Expand Up @@ -937,4 +938,108 @@ describe('Form.List', () => {

expect(formRef.current!.getFieldValue('list')).toEqual([{ user: '1' }, { user: '3' }]);
});

it('list unmount', async () => {
const onFinish = jest.fn();
const formRef = React.createRef<FormInstance>();

const Demo = () => {
const [isShow, setIsShow] = useState(true);
return (
<Form
initialValues={{
users: [
{ name: 'a', age: '1' },
{ name: 'b', age: '2' },
],
}}
ref={formRef}
onFinish={onFinish}
>
<Form.List name="users">
{fields => {
return fields.map(field => (
<div key={field.key} style={{ display: 'flex', gap: 10 }}>
<InfoField name={[field.name, 'name']}>
<Input />
</InfoField>
{isShow && (
<InfoField name={[field.name, 'age']}>
<Input />
</InfoField>
)}
</div>
));
}}
</Form.List>
<button data-testid="hide" type="button" onClick={() => setIsShow(c => !c)}>
隐藏
</button>
<button type="submit" data-testid="submit">
Submit
</button>
</Form>
);
};

const { queryByTestId } = render(<Demo />);
fireEvent.click(queryByTestId('submit'));
await act(async () => {
await timeout();
});
expect(onFinish).toHaveBeenCalledWith({
users: [
{ name: 'a', age: '1' },
{ name: 'b', age: '2' },
],
});
expect(formRef.current?.getFieldsValue()).toEqual({
users: [
{ name: 'a', age: '1' },
{ name: 'b', age: '2' },
],
});
onFinish.mockReset();

fireEvent.click(queryByTestId('hide'));
fireEvent.click(queryByTestId('submit'));
await act(async () => {
await timeout();
});
expect(onFinish).toHaveBeenCalledWith({ users: [{ name: 'a' }, { name: 'b' }] });
expect(formRef.current?.getFieldsValue()).toEqual({
users: [{ name: 'a' }, { name: 'b' }],
});
});

it('list rules', async () => {
const onFinishFailed = jest.fn();

const Demo = () => {
return (
<Form onFinishFailed={onFinishFailed}>
<Form.List name="users" rules={[{ validator: () => Promise.reject('error') }]}>
{fields => {
return fields.map(field => (
<InfoField name={[field.name, 'name']} key={field.key}>
<Input />
</InfoField>
));
}}
</Form.List>
<button type="submit" data-testid="submit">
Submit
</button>
</Form>
);
};

const { queryByTestId } = render(<Demo />);
fireEvent.click(queryByTestId('submit'));
await act(async () => {
await timeout();
});

expect(onFinishFailed).toHaveBeenCalled();
});
});