import * as React from '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'; import { testDataProvider } from './testDataProvider'; import { useDelete } from './useDelete'; import { ErrorCase as ErrorCasePessimistic, SuccessCase as SuccessCasePessimistic, InList, } from './useDelete.pessimistic.stories'; import { ErrorCase as ErrorCaseOptimistic, SuccessCase as SuccessCaseOptimistic, } from './useDelete.optimistic.stories'; import { ErrorCase as ErrorCaseUndoable, SuccessCase as SuccessCaseUndoable, } from './useDelete.undoable.stories'; import { MutationMode, Params, InvalidateList } from './useDelete.stories'; describe('useDelete', () => { it('returns a callback that can be used with deleteOne arguments', async () => { const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete(); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { id: 1, bar: 'bar' } }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); }); }); it('returns a callback that can be used with no arguments', async () => { const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne(); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); }); }); it('uses a custom mutationFn when provided in options', async () => { const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)), }); const customMutationFn = jest.fn(async params => ({ id: params.id, source: 'custom', })); let localDeleteOne; let mutationData; const Dummy = () => { const [deleteOne, { data }] = useDelete(undefined, undefined, { mutationFn: customMutationFn, }); localDeleteOne = deleteOne; mutationData = data; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { id: 1, bar: 'bar' } }); await waitFor(() => { expect(customMutationFn).toHaveBeenCalledWith({ resource: 'foo', id: 1, previousData: { id: 1, bar: 'bar' }, }); }); expect(dataProvider.delete).not.toHaveBeenCalled(); await waitFor(() => { expect(mutationData).toEqual({ id: 1, source: 'custom', }); }); }); it('uses the latest declaration time mutationMode', async () => { const posts = [ { id: 1, title: 'Hello' }, { id: 2, title: 'World' }, ]; let resolveDelete: (() => void) | undefined; const dataProvider = { getList: () => Promise.resolve({ data: posts, total: posts.length, }), delete: jest.fn((_, params) => { return new Promise(resolve => { resolveDelete = () => { const index = posts.findIndex( post => post.id === params.id ); if (index !== -1) { posts.splice(index, 1); } resolve({ data: params.previousData }); }; }); }), } as any; // This story uses the pessimistic mode by default render(); 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(); }); resolveDelete?.(); 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 () => { const posts = [ { id: 1, title: 'Hello' }, { id: 2, title: 'World' }, ]; let resolveDelete: (() => void) | undefined; const dataProvider = { getList: () => Promise.resolve({ data: posts, total: posts.length, }), delete: jest.fn((_, params) => { return new Promise(resolve => { resolveDelete = () => { const index = posts.findIndex( post => post.id === params.id ); if (index !== -1) { posts.splice(index, 1); } resolve({ data: params.previousData }); }; }); }), } as any; // This story has no meta by default render(); 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(); }); resolveDelete?.(); 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)), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { foo: 456 }, }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { foo: 456 }, }); }); }); it('calls onSettled when provided in hook time options', async () => { const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)), }); let localDeleteOne; const onSettled = jest.fn(); const Dummy = () => { const [deleteOne] = useDelete( 'foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }, { onSettled } ); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { foo: 456 }, }); await waitFor(() => { expect(onSettled).toHaveBeenCalled(); }); }); it('accepts a meta parameter', async () => { const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete(); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, meta: { hello: 'world' }, }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, meta: { hello: 'world' }, }); }); }); it('sets the mutationKey', async () => { const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any)), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete('foo'); localDeleteOne = deleteOne; return ; }; const Observe = () => { const mutation = useMutationState({ filters: { mutationKey: ['foo', 'delete'], }, }); return mutations: {mutation.length}; }; render( ); localDeleteOne('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, meta: { hello: 'world' }, }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, meta: { hello: 'world' }, }); }); await screen.findByText('mutations: 1'); }); it('returns data typed based on the parametric type', async () => { interface Product extends RaRecord { sku: string; } const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 1, sku: 'abc' } } as any) ), }); let localDeleteOne; let sku; const Dummy = () => { const [deleteOne, { data }] = useDelete(); localDeleteOne = deleteOne; sku = data && data.sku; return ; }; render( ); expect(sku).toBeUndefined(); localDeleteOne('products', { id: 1, previousData: { id: 1, sku: 'bcd' }, }); await waitFor(() => { expect(sku).toEqual('abc'); }); }); it('should delete record even if id is zero', async () => { const dataProvider = testDataProvider({ delete: jest.fn(() => Promise.resolve({ data: { id: 0 } } as any)), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete(); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 0, previousData: { id: 0, bar: 'bar' } }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 0, previousData: { id: 0, bar: 'bar' }, }); }); }); describe('mutationOptions', () => { it('when pessimistic, executes success side effects on success', async () => { const onSuccess = jest.fn(); const dataProvider = testDataProvider({ delete: () => Promise.resolve({ data: { id: 1 } } as any), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete( 'foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }, { onSuccess } ); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { foo: 456 }, }); await waitFor(() => { expect(onSuccess).toHaveBeenCalledWith( { id: 1 }, { id: 1, previousData: { foo: 456 }, resource: 'foo' }, { snapshot: [] }, expect.anything() ); }); }); it('when pessimistic, executes error side effects on error', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); const onError = jest.fn(); const dataProvider = testDataProvider({ delete: () => Promise.reject(new Error('not good')), }); let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete( 'foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }, { onError } ); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { foo: 456 }, }); await waitFor(() => { expect(onError).toHaveBeenCalledWith( new Error('not good'), { id: 1, previousData: { foo: 456 }, resource: 'foo' }, { snapshot: [] }, expect.anything() ); }); }); }); describe('mutationMode', () => { it('when pessimistic, displays result and success side effects when dataProvider promise resolves', async () => { render(); screen.getByText('Delete first post').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); expect(screen.queryByText('mutating')).not.toBeNull(); }); expect(screen.queryByText('Hello')).not.toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); expect(screen.queryByText('Hello')).toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); }); it('when pessimistic, displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); screen.getByText('Delete first post').click(); await waitFor( () => { expect(screen.queryByText('success')).toBeNull(); expect( screen.queryByText('something went wrong') ).toBeNull(); expect(screen.queryByText('Hello')).not.toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).not.toBeNull(); }, { timeout: 4000 } ); await waitFor( () => { expect(screen.queryByText('success')).toBeNull(); expect( screen.queryByText('something went wrong') ).not.toBeNull(); expect(screen.queryByText('Hello')).not.toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }, { timeout: 4000 } ); }); it('when pessimistic, forces a refresh of related queries', async () => { render(); await screen.findByText('Books 1-10 on 25'); // Element #2 is there expect(screen.queryByText('The Little Prince')).not.toBeNull(); // Element #11 is not in this page expect(screen.queryByText('The Chronicles of Narnia')).toBeNull(); // Delete element #2 const TheLittlePrinceDeleteButton = screen.queryAllByText('Delete')[1]; TheLittlePrinceDeleteButton.click(); await screen.findByText('Books 1-10 on 24'); // Element #2 is not there anymore expect(screen.queryByText('The Little Prince')).toBeNull(); // The list was refetched, so a new element appeared at the end expect( screen.queryByText('The Chronicles of Narnia') ).not.toBeNull(); }); it('when optimistic, displays result and success side effects right away', async () => { render(); await waitFor(() => new Promise(resolve => setTimeout(resolve, 0))); screen.getByText('Delete first post').click(); 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('when optimistic, displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); await waitFor(() => new Promise(resolve => setTimeout(resolve, 0))); screen.getByText('Delete first post').click(); 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')).toBeNull(); expect( screen.queryByText('something went wrong') ).not.toBeNull(); expect(screen.queryByText('Hello')).not.toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); }); it('when undoable, displays result and success side effects right away and fetched on confirm', async () => { render(); await waitFor(() => new Promise(resolve => setTimeout(resolve, 0))); screen.getByText('Delete first post').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('Hello')).toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); screen.getByText('Confirm').click(); 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(); }, { timeout: 4000 } ); }); it('when undoable, displays result and success side effects right away and reverts on cancel', async () => { render(); await waitFor(() => new Promise(resolve => setTimeout(resolve, 0))); screen.getByText('Delete first post').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('Hello')).toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); screen.getByText('Cancel').click(); await waitFor(() => { expect(screen.queryByText('Hello')).not.toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); }); it('when undoable, displays result and success side effects right away and reverts on error', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render(); await waitFor(() => new Promise(resolve => setTimeout(resolve, 0))); screen.getByText('Delete first post').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('Hello')).toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); screen.getByText('Confirm').click(); 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')).toBeNull(); expect(screen.queryByText('Hello')).not.toBeNull(); expect(screen.queryByText('World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }, { timeout: 4000 } ); }); }); describe('query cache', () => { it('updates getList query cache when dataProvider promise resolves', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(['foo', 'getList'], { data: [ { id: 1, bar: 'bar' }, { id: 2, bar: 'bar' }, ], total: 2, }); const dataProvider = { delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any) ), } as any; let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete( 'foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }, { mutationMode: 'optimistic' } ); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); }); await waitFor(() => { expect(queryClient.getQueryData(['foo', 'getList'])).toEqual({ data: [{ id: 2, bar: 'bar' }], total: 1, }); }); }); it('updates getList query cache with pageInfo when dataProvider promise resolves', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(['foo', 'getList'], { data: [ { id: 1, bar: 'bar' }, { id: 2, bar: 'bar' }, ], pageInfo: { hasPreviousPage: false, hasNextPage: true, }, }); const dataProvider = { delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any) ), } as any; let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete( 'foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }, { mutationMode: 'optimistic' } ); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); }); await waitFor(() => { expect(queryClient.getQueryData(['foo', 'getList'])).toEqual({ data: [{ id: 2, bar: 'bar' }], pageInfo: { hasPreviousPage: false, hasNextPage: true, }, }); }); }); it('updates getInfiniteList query cache when dataProvider promise resolves', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(['foo', 'getInfiniteList'], { pages: [ { data: [ { id: 1, bar: 'bar' }, { id: 2, bar: 'bar' }, ], total: 2, }, ], pageParams: [], }); const dataProvider = { delete: jest.fn(() => Promise.resolve({ data: { id: 1 } } as any) ), } as any; let localDeleteOne; const Dummy = () => { const [deleteOne] = useDelete( 'foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }, { mutationMode: 'optimistic' } ); localDeleteOne = deleteOne; return ; }; render( ); localDeleteOne('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); await waitFor(() => { expect(dataProvider.delete).toHaveBeenCalledWith('foo', { id: 1, previousData: { id: 1, bar: 'bar' }, }); }); await waitFor(() => { expect( queryClient.getQueryData(['foo', 'getInfiniteList']) ).toEqual({ pages: [ { data: [{ id: 2, bar: 'bar' }], total: 1, }, ], pageParams: [], }); }); }); it('invalidates getList query when dataProvider resolves in undoable mode', async () => { render(); await screen.findByText('Title: Hello'); fireEvent.click(await screen.findByText('Delete')); await screen.findByText('resources.posts.notifications.deleted'); fireEvent.click(screen.getByText('Close')); await waitFor(() => { expect(screen.queryByText('1: Hello')).toBeNull(); }); }); }); });