Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
43 changes: 41 additions & 2 deletions packages/ra-core/src/dataProvider/useCreate.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { render, waitFor, screen } from '@testing-library/react';
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
import expect from 'expect';
import { QueryClient, useMutationState } from '@tanstack/react-query';

import { RaRecord } from '../types';
import { testDataProvider } from './testDataProvider';
Expand All @@ -25,7 +26,7 @@ import {
WithMiddlewaresSuccess as WithMiddlewaresSuccessUndoable,
WithMiddlewaresError as WithMiddlewaresErrorUndoable,
} from './useCreate.undoable.stories';
import { QueryClient, useMutationState } from '@tanstack/react-query';
import { MutationMode, Params } from './useCreate.stories';

describe('useCreate', () => {
it('returns a callback that can be used with create arguments', async () => {
Expand Down Expand Up @@ -76,6 +77,44 @@ describe('useCreate', () => {
});
});

it('uses the latest declaration time mutationMode', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
// This story uses the pessimistic mode by default
render(<MutationMode />);
fireEvent.click(screen.getByText('Change mutation mode to optimistic'));
fireEvent.click(screen.getByText('Create post'));
// Should display the optimistic result right away if the change was handled
await waitFor(() => {
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Hello World')).not.toBeNull();
expect(screen.queryByText('mutating')).not.toBeNull();
});
await waitFor(() => {
expect(screen.queryByText('mutating')).toBeNull();
});
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Hello World')).not.toBeNull();
});

it('uses the latest declaration time params', async () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
// This story sends the Hello World title by default
render(<Params />);
fireEvent.click(screen.getByText('Change params'));
fireEvent.click(screen.getByText('Create post'));
// Should have changed the title to Goodbye World
await waitFor(() => {
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Goodbye World')).not.toBeNull();
expect(screen.queryByText('mutating')).not.toBeNull();
});
await waitFor(() => {
expect(screen.queryByText('mutating')).toBeNull();
});
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Goodbye World')).not.toBeNull();
});

it('uses call time params over hook time params', async () => {
const dataProvider = testDataProvider({
create: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)),
Expand Down
210 changes: 210 additions & 0 deletions packages/ra-core/src/dataProvider/useCreate.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import * as React from 'react';
import { QueryClient, useIsMutating } from '@tanstack/react-query';

import { CoreAdminContext } from '../core';
import { useCreate } from './useCreate';
import { useGetOne } from './useGetOne';
import type { MutationMode as MutationModeType } from '../types';

export default { title: 'ra-core/dataProvider/useCreate' };

export const MutationMode = ({ timeout = 1000 }) => {
const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }];
const dataProvider = {
getOne: (resource, params) => {
return new Promise((resolve, reject) => {
const data = posts.find(p => p.id === params.id);
setTimeout(() => {
if (!data) {
reject(new Error('nothing yet'));
}
resolve({ data });
}, timeout);
});
},
create: (resource, params) => {
return new Promise(resolve => {
setTimeout(() => {
posts.push(params.data);
resolve({ data: params.data });
}, timeout);
});
},
} as any;
return (
<CoreAdminContext
queryClient={
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}
dataProvider={dataProvider}
>
<MutationModeCore />
</CoreAdminContext>
);
};

const MutationModeCore = () => {
const isMutating = useIsMutating();
const [success, setSuccess] = React.useState<string>();
const [mutationMode, setMutationMode] =
React.useState<MutationModeType>('pessimistic');

const {
isPending: isPendingGetOne,
data,
error,
refetch,
} = useGetOne('posts', { id: 2 });
const [create, { isPending }] = useCreate(
'posts',
{
data: { id: 2, title: 'Hello World' },
},
{
mutationMode,
onSuccess: () => setSuccess('success'),
}
);
const handleClick = () => {
create();
};
return (
<>
{isPendingGetOne ? (
<p>Loading...</p>
) : error ? (
<p>{error.message}</p>
) : (
<dl>
<dt>id</dt>
<dd>{data?.id}</dd>
<dt>title</dt>
<dd>{data?.title}</dd>
<dt>author</dt>
<dd>{data?.author}</dd>
</dl>
)}
<div>
<button onClick={handleClick} disabled={isPending}>
Create post
</button>
&nbsp;
<button
onClick={() => setMutationMode('optimistic')}
disabled={isPending}
>
Change mutation mode to optimistic
</button>
&nbsp;
<button onClick={() => refetch()}>Refetch</button>
</div>
{success && <div>{success}</div>}
{isMutating !== 0 && <div>mutating</div>}
</>
);
};

export const Params = ({ timeout = 1000 }) => {
const posts = [{ id: 1, title: 'Hello', author: 'John Doe' }];
const dataProvider = {
getOne: (resource, params) => {
return new Promise((resolve, reject) => {
const data = posts.find(p => p.id === params.id);
setTimeout(() => {
if (!data) {
reject(new Error('nothing yet'));
}
resolve({ data });
}, timeout);
});
},
create: (resource, params) => {
return new Promise(resolve => {
setTimeout(() => {
posts.push(params.data);
resolve({ data: params.data });
}, timeout);
});
},
} as any;
return (
<CoreAdminContext
queryClient={
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}
dataProvider={dataProvider}
>
<ParamsCore />
</CoreAdminContext>
);
};

const ParamsCore = () => {
const isMutating = useIsMutating();
const [success, setSuccess] = React.useState<string>();
const [params, setParams] = React.useState<any>({ title: 'Hello World' });

const {
isPending: isPendingGetOne,
data,
error,
refetch,
} = useGetOne('posts', { id: 2 });
const [create, { isPending }] = useCreate(
'posts',
{
data: { id: 2, ...params },
},
{
mutationMode: 'optimistic',
onSuccess: () => setSuccess('success'),
}
);
const handleClick = () => {
create();
};
return (
<>
{isPendingGetOne ? (
<p>Loading...</p>
) : error ? (
<p>{error.message}</p>
) : (
<dl>
<dt>id</dt>
<dd>{data?.id}</dd>
<dt>title</dt>
<dd>{data?.title}</dd>
<dt>author</dt>
<dd>{data?.author}</dd>
</dl>
)}
<div>
<button onClick={handleClick} disabled={isPending}>
Create post
</button>
&nbsp;
<button
onClick={() => setParams({ title: 'Goodbye World' })}
disabled={isPending}
>
Change params
</button>
&nbsp;
<button onClick={() => refetch()}>Refetch</button>
</div>
{success && <div>{success}</div>}
{isMutating !== 0 && <div>mutating</div>}
</>
);
};
9 changes: 8 additions & 1 deletion packages/ra-core/src/dataProvider/useCreate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useRef } from 'react';
import { useEffect, useMemo, useRef } from 'react';
import {
useMutation,
UseMutationOptions,
Expand Down Expand Up @@ -97,9 +97,16 @@ export const useCreate = <
...mutationOptions
} = options;
const mode = useRef<MutationMode>(mutationMode);
useEffect(() => {
mode.current = mutationMode;
}, [mutationMode]);

const paramsRef =
useRef<Partial<CreateParams<Partial<RecordType>>>>(params);
useEffect(() => {
paramsRef.current = params;
}, [params]);

const snapshot = useRef<Snapshot>([]);

// Ref that stores the mutation with middlewares to avoid losing them if the calling component is unmounted
Expand Down
78 changes: 76 additions & 2 deletions packages/ra-core/src/dataProvider/useDelete.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { screen, render, waitFor } from '@testing-library/react';
import { screen, render, waitFor, fireEvent } from '@testing-library/react';
import expect from 'expect';
import { QueryClient, useMutationState } from '@tanstack/react-query';

import { CoreAdminContext } from '../core';
import { RaRecord } from '../types';
Expand All @@ -19,7 +20,7 @@ import {
ErrorCase as ErrorCaseUndoable,
SuccessCase as SuccessCaseUndoable,
} from './useDelete.undoable.stories';
import { QueryClient, useMutationState } from '@tanstack/react-query';
import { MutationMode, Params } from './useDelete.stories';

describe('useDelete', () => {
it('returns a callback that can be used with deleteOne arguments', async () => {
Expand Down Expand Up @@ -75,6 +76,79 @@ describe('useDelete', () => {
});
});

it('uses the latest declaration time mutationMode', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
// This story uses the pessimistic mode by default
render(<MutationMode />);
await waitFor(() => new Promise(resolve => setTimeout(resolve, 0)));
fireEvent.click(screen.getByText('Change mutation mode to optimistic'));
fireEvent.click(screen.getByText('Delete first post'));
// Should display the optimistic result right away if the change was handled
await waitFor(() => {
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Hello')).toBeNull();
expect(screen.queryByText('World')).not.toBeNull();
expect(screen.queryByText('mutating')).not.toBeNull();
});
await waitFor(() => {
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Hello')).toBeNull();
expect(screen.queryByText('World')).not.toBeNull();
expect(screen.queryByText('mutating')).toBeNull();
});
});

it('uses the latest declaration time params', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
const posts = [
{ id: 1, title: 'Hello' },
{ id: 2, title: 'World' },
];
const dataProvider = {
getList: (resource, params) => {
console.log('getList', resource, params);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you should not leave the console.log here (same in delete and in other hook tests)

return Promise.resolve({
data: posts,
total: posts.length,
});
},
delete: jest.fn((resource, params) => {
console.log('delete', resource, params);
return new Promise(resolve => {
setTimeout(() => {
const index = posts.findIndex(p => p.id === params.id);
posts.splice(index, 1);
resolve({ data: params.previousData });
}, 1000);
});
}),
} as any;
// This story has no meta by default
render(<Params dataProvider={dataProvider} />);
await waitFor(() => new Promise(resolve => setTimeout(resolve, 0)));
fireEvent.click(screen.getByText('Change params'));
fireEvent.click(screen.getByText('Delete first post'));
// Should display the optimistic result right away if the change was handled
await waitFor(() => {
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Hello')).toBeNull();
expect(screen.queryByText('World')).not.toBeNull();
expect(screen.queryByText('mutating')).not.toBeNull();
});
await waitFor(() => {
expect(screen.queryByText('success')).not.toBeNull();
expect(screen.queryByText('Hello')).toBeNull();
expect(screen.queryByText('World')).not.toBeNull();
expect(screen.queryByText('mutating')).toBeNull();
});

expect(dataProvider.delete).toHaveBeenCalledWith('posts', {
id: 1,
previousData: { id: 1, title: 'Hello' },
meta: 'test',
});
});

it('uses call time params over hook time params', async () => {
const dataProvider = testDataProvider({
delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)),
Expand Down
Loading
Loading