Skip to content

Commit 2510900

Browse files
committed
Fix <SaveButton> form dirty status check
1 parent cc477d8 commit 2510900

File tree

4 files changed

+164
-7
lines changed

4 files changed

+164
-7
lines changed

packages/ra-ui-materialui/src/button/SaveButton.spec.tsx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { AdminContext } from '../AdminContext';
2323
import {
2424
AlwaysEnable,
2525
Basic,
26+
ComplexForm,
2627
EnabledWhenFormIsPrefilled,
2728
} from './SaveButton.stories';
2829

@@ -401,7 +402,7 @@ describe('<SaveButton />', () => {
401402
);
402403
});
403404

404-
it('should render enabled if the form is prefilled', async () => {
405+
it('should be enabled if the form is prefilled', async () => {
405406
render(<EnabledWhenFormIsPrefilled />);
406407
await waitFor(() =>
407408
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
@@ -410,6 +411,73 @@ describe('<SaveButton />', () => {
410411
);
411412
});
412413

414+
it('should enable/disable consistently in a complex form', async () => {
415+
render(<ComplexForm />);
416+
await waitFor(() =>
417+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
418+
true
419+
)
420+
);
421+
fireEvent.change(await screen.getByDisplayValue('Lorem ipsum'), {
422+
target: { value: 'Lorem ipsum dolor' },
423+
});
424+
await waitFor(() =>
425+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
426+
false
427+
)
428+
);
429+
fireEvent.click(screen.getByText('ra.action.save'));
430+
await waitFor(() =>
431+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
432+
true
433+
)
434+
);
435+
fireEvent.change(await screen.getByDisplayValue('bazinga'), {
436+
target: { value: 'bazingaaaa' },
437+
});
438+
439+
await waitFor(() =>
440+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
441+
false
442+
)
443+
);
444+
fireEvent.click(screen.getByText('ra.action.save'));
445+
await waitFor(() =>
446+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
447+
true
448+
)
449+
);
450+
fireEvent.click(screen.getByLabelText('ra.action.add'));
451+
await waitFor(() =>
452+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
453+
false
454+
)
455+
);
456+
await waitFor(() =>
457+
expect(
458+
screen.queryAllByLabelText('resources.posts.fields.tags.name')
459+
.length
460+
).toEqual(2)
461+
);
462+
fireEvent.change(
463+
screen.getAllByLabelText('resources.posts.fields.tags.name')[1],
464+
{
465+
target: { value: 'plop' },
466+
}
467+
);
468+
await waitFor(() =>
469+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
470+
false
471+
)
472+
);
473+
fireEvent.click(screen.getByText('ra.action.save'));
474+
await waitFor(() =>
475+
expect(screen.getByLabelText('ra.action.save')['disabled']).toEqual(
476+
true
477+
)
478+
);
479+
});
480+
413481
it('should not be enabled if no inputs have changed', async () => {
414482
render(
415483
<AdminContext dataProvider={testDataProvider()}>

packages/ra-ui-materialui/src/button/SaveButton.stories.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import * as React from 'react';
2-
import { Form, TestMemoryRouter } from 'ra-core';
2+
import { Form, ResourceContextProvider, TestMemoryRouter } from 'ra-core';
33
import { Paper } from '@mui/material';
4+
import fakeRestDataProvider from 'ra-data-fakerest';
5+
import { useFormContext } from 'react-hook-form';
46

57
import { SaveButton } from './SaveButton';
8+
import { ArrayInput } from '../input/ArrayInput/ArrayInput';
9+
import { TextInput } from '../input/TextInput';
10+
import { SimpleFormIterator } from '../input/ArrayInput/SimpleFormIterator';
611
import { AdminContext } from '../AdminContext';
7-
import { useFormContext } from 'react-hook-form';
12+
import { Edit } from '../detail';
813

914
export default {
1015
title: 'ra-ui-materialui/button/SaveButton',
@@ -81,3 +86,45 @@ export const Submitting = () => (
8186
</AdminContext>
8287
</TestMemoryRouter>
8388
);
89+
90+
export const ComplexForm = () => (
91+
<AdminContext
92+
dataProvider={fakeRestDataProvider(
93+
{
94+
posts: [
95+
{
96+
id: 1,
97+
title: 'Lorem ipsum',
98+
tags: [{ name: 'bazinga' }],
99+
},
100+
],
101+
},
102+
process.env.NODE_ENV !== 'test',
103+
300
104+
)}
105+
>
106+
<Paper>
107+
<ResourceContextProvider value="posts">
108+
<Edit id={1} redirect={false}>
109+
<Form>
110+
<TextInput source="title" />
111+
<ArrayInput source="tags">
112+
<SimpleFormIterator>
113+
<TextInput source="name" />
114+
</SimpleFormIterator>
115+
</ArrayInput>
116+
<SaveButton />
117+
<FormInspector />
118+
</Form>
119+
</Edit>
120+
</ResourceContextProvider>
121+
</Paper>
122+
</AdminContext>
123+
);
124+
125+
const FormInspector = () => {
126+
const {
127+
formState: { isDirty, dirtyFields },
128+
} = useFormContext();
129+
return <p>{JSON.stringify({ isDirty, dirtyFields })}</p>;
130+
};

packages/ra-ui-materialui/src/button/SaveButton.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
setSubmissionErrors,
2121
useRecordFromLocation,
2222
} from 'ra-core';
23+
import isEmpty from 'lodash/isEmpty';
2324

2425
/**
2526
* Submit button for resource forms (Edit and Create).
@@ -74,7 +75,7 @@ export const SaveButton = <RecordType extends RaRecord = any>(
7475
const saveContext = useSaveContext();
7576
const { dirtyFields, isValidating, isSubmitting } = useFormState();
7677
// useFormState().isDirty might differ from useFormState().dirtyFields (https://github.com/react-hook-form/react-hook-form/issues/4740)
77-
const isDirty = Object.keys(dirtyFields).length > 0;
78+
const isDirty = hasDirtyFields(dirtyFields);
7879
// Use form isDirty, isValidating and form context saving to enable or disable the save button
7980
// if alwaysEnable is undefined and the form wasn't prefilled
8081
const recordFromLocation = useRecordFromLocation();
@@ -226,3 +227,44 @@ declare module '@mui/material/styles' {
226227
};
227228
}
228229
}
230+
231+
const hasDirtyFields = (
232+
dirtyFields: Partial<
233+
Readonly<{
234+
[x: string]: any;
235+
}>
236+
>
237+
): boolean => {
238+
// dirtyFields can contains simple keys with boolean values, nested objects or arrays
239+
// We must ignore values that are false
240+
return Object.values(dirtyFields).some(value => {
241+
if (typeof value === 'boolean') {
242+
return value;
243+
} else if (Array.isArray(value)) {
244+
// Some arrays contain only booleans (scalar arrays), some arrays contain objects (object arrays)
245+
for (const item of value) {
246+
if (item === true) {
247+
return true;
248+
}
249+
// FIXME: because we currently don't set default values correctly for arrays,
250+
// new items are either empty objects, or undefined in dirtyFields. Consider them as dirty.
251+
if (
252+
(typeof item === 'object' && isEmpty(item)) ||
253+
item === undefined
254+
) {
255+
return true;
256+
}
257+
if (
258+
typeof item === 'object' &&
259+
item !== null &&
260+
hasDirtyFields(item)
261+
) {
262+
return true;
263+
}
264+
}
265+
} else if (typeof value === 'object' && value !== null) {
266+
return hasDirtyFields(value);
267+
}
268+
return false;
269+
});
270+
};

yarn.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19904,11 +19904,11 @@ __metadata:
1990419904
linkType: hard
1990519905

1990619906
"react-hook-form@npm:^7.53.0":
19907-
version: 7.53.0
19908-
resolution: "react-hook-form@npm:7.53.0"
19907+
version: 7.53.2
19908+
resolution: "react-hook-form@npm:7.53.2"
1990919909
peerDependencies:
1991019910
react: ^16.8.0 || ^17 || ^18 || ^19
19911-
checksum: 6d62b150618a833c17d59e669b707661499e2bb516a8d340ca37699f99eb448bbba7b5b78324938c8948014e21efaa32e3510c2ba246fd5e97a96fca0cfa7c98
19911+
checksum: 18336d8e8798a70dcd0af703a0becca2d5dbf82a7b7a3ca334ae0e1f26410490bc3ef2ea51adcf790bb1e7006ed7a763fd00d664e398f71225b23529a7ccf0bf
1991219912
languageName: node
1991319913
linkType: hard
1991419914

0 commit comments

Comments
 (0)