import * as React from 'react'; import { screen, render, waitFor, act, fireEvent, } from '@testing-library/react'; import { QueryClient, useMutationState } from '@tanstack/react-query'; import expect from 'expect'; import { testDataProvider } from './testDataProvider'; import { CoreAdminContext } from '../core'; import { useUpdateMany } from './useUpdateMany'; import { MutationMode, Params, UndefinedValues, WithMiddlewares, InvalidateList, } from './useUpdateMany.stories'; describe('useUpdateMany', () => { it('returns a callback that can be used with update arguments', async () => { const dataProvider = testDataProvider({ updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any)), }); let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany(); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' } }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); }); it('returns a callback that can be used with no arguments', async () => { const dataProvider = testDataProvider({ updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any)), }); let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' }, }); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany(); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); }); it('uses a custom mutationFn with mutation middlewares', async () => { const dataProvider = testDataProvider({ updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any)), }); const customMutationFn = jest.fn(async params => params.ids ?? []); let localUpdateMany; let mutationData; const Dummy = () => { const [updateMany, { data }] = useUpdateMany(undefined, undefined, { mutationFn: customMutationFn, getMutateWithMiddlewares: mutate => async (resource, params) => mutate(resource, { ...params, data: { ...params.data, middlewareApplied: true, }, }), }); localUpdateMany = updateMany; mutationData = data; return ; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { title: 'Hello' }, }); await waitFor(() => { expect(customMutationFn).toHaveBeenCalledWith({ resource: 'foo', ids: [1, 2], data: { title: 'Hello', middlewareApplied: true, }, }); }); expect(dataProvider.updateMany).not.toHaveBeenCalled(); await waitFor(() => { expect(mutationData).toEqual([1, 2]); }); }); it('uses the latest declaration time mutationMode', async () => { // This story uses the pessimistic mode by default render(); await screen.findByText( '[{"id":1,"title":"foo"},{"id":2,"title":"bar"}]' ); fireEvent.click(screen.getByText('Change mutation mode to optimistic')); fireEvent.click(screen.getByText('Update title')); await screen.findByText( '[{"id":1,"title":"world"},{"id":2,"title":"world"}]' ); // and not [{"title":"world"},{"title":"world"}] }); it('uses the latest declaration time params', async () => { const data = [ { id: 1, title: 'foo' }, { id: 2, title: 'bar' }, ]; const dataProvider = { getList: async () => ({ data, total: 2 }), updateMany: jest.fn(() => new Promise(() => {})), // never resolve to see only optimistic update } as any; // This story sends no meta by default render(); await screen.findByText( '[{"id":1,"title":"foo"},{"id":2,"title":"bar"}]' ); fireEvent.click(screen.getByText('Change params')); fireEvent.click(screen.getByText('Update title')); await screen.findByText( '[{"id":1,"title":"world"},{"id":2,"title":"world"}]' ); // and not [{"title":"world"},{"title":"world"}] expect(dataProvider.updateMany).toHaveBeenCalledWith('posts', { ids: [1, 2], data: { title: 'world' }, meta: 'test', }); }); it('uses callback call time params rather than hook call time params', async () => { const dataProvider = testDataProvider({ updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any)), }); let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' }, }); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany('foo', { data: { foo: 456 } }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { foo: 456 }, }); }); }); it('accepts a meta parameter', async () => { const dataProvider = testDataProvider({ updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any)), }); let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany(); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' }, meta: { hello: 'world' }, }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, meta: { hello: 'world' }, }); }); }); it('sets the mutationKey', async () => { const dataProvider = testDataProvider({ updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any)), }); let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany('foo'); localUpdateMany = updateMany; return ; }; const Observe = () => { const mutation = useMutationState({ filters: { mutationKey: ['foo', 'updateMany'], }, }); return mutations: {mutation.length}; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' }, meta: { hello: 'world' }, }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, meta: { hello: 'world' }, }); }); await screen.findByText('mutations: 1'); }); 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 = { updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any) ), } as any; let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany(); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' } }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); await waitFor(() => { expect(queryClient.getQueryData(['foo', 'getList'])).toEqual({ data: [ { id: 1, bar: 'baz' }, { id: 2, bar: 'baz' }, ], total: 2, }); }); }); it('updates getList query cache when dataProvider promise resolves in optimistic mode', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(['foo', 'getList'], { data: [ { id: 1, bar: 'bar' }, { id: 2, bar: 'bar' }, ], total: 2, }); const dataProvider = { updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any) ), } as any; let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany(undefined, undefined, { mutationMode: 'optimistic', }); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' } }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); await waitFor(() => { expect(queryClient.getQueryData(['foo', 'getList'])).toEqual({ data: [ { id: 1, bar: 'baz' }, { id: 2, bar: 'baz' }, ], total: 2, }); }); }); it('updates getList query cache when dataProvider promise resolves and using no call-time params', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(['foo', 'getList'], { data: [ { id: 1, bar: 'bar' }, { id: 2, bar: 'bar' }, ], total: 2, }); const dataProvider = { updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any) ), } as any; let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' }, }); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany(); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); await waitFor(() => { expect(queryClient.getQueryData(['foo', 'getList'])).toEqual({ data: [ { id: 1, bar: 'baz' }, { id: 2, bar: 'baz' }, ], total: 2, }); }); }); it('updates getList query cache when dataProvider promise resolves in optimistic mode with no call-time params', async () => { const queryClient = new QueryClient(); queryClient.setQueryData(['foo', 'getList'], { data: [ { id: 1, bar: 'bar' }, { id: 2, bar: 'bar' }, ], total: 2, }); const dataProvider = { updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any) ), } as any; let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany( 'foo', { ids: [1, 2], data: { bar: 'baz' }, }, { mutationMode: 'optimistic' } ); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany(); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); await waitFor(() => { expect(queryClient.getQueryData(['foo', 'getList'])).toEqual({ data: [ { id: 1, bar: 'baz' }, { id: 2, bar: 'baz' }, ], total: 2, }); }); }); 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 = { updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any) ), } as any; let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' }, }); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' } }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); await waitFor(() => { expect(queryClient.getQueryData(['foo', 'getList'])).toEqual({ data: [ { id: 1, bar: 'baz' }, { id: 2, bar: 'baz' }, ], 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 = { updateMany: jest.fn(() => Promise.resolve({ data: [1, 2] } as any) ), } as any; let localUpdateMany; const Dummy = () => { const [updateMany] = useUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' }, }); localUpdateMany = updateMany; return ; }; render( ); localUpdateMany('foo', { ids: [1, 2], data: { bar: 'baz' } }); await waitFor(() => { expect(dataProvider.updateMany).toHaveBeenCalledWith('foo', { ids: [1, 2], data: { bar: 'baz' }, }); }); await waitFor(() => { expect( queryClient.getQueryData(['foo', 'getInfiniteList']) ).toEqual({ pages: [ { data: [ { id: 1, bar: 'baz' }, { id: 2, bar: 'baz' }, ], total: 2, }, ], pageParams: [], }); }); }); it('when optimistic, does not erase values if the payload contains undefined values', async () => { render(); await screen.findByText( '[{"id":1,"title":"foo"},{"id":2,"title":"bar"}]' ); screen.getByText('Update title').click(); await screen.findByText( '[{"id":1,"title":"world"},{"id":2,"title":"world"}]' ); // and not [{"title":"world"},{"title":"world"}] }); it('invalidates getList query dataProvider resolves in undoable mode', async () => { render(); fireEvent.click(await screen.findByText('Update')); await screen.findByText('resources.posts.notifications.updated'); fireEvent.click(screen.getByText('Close')); await screen.findByText('1: Hello updated'); }); }); describe('middlewares', () => { it('when pessimistic, it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { render(); await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); expect( screen.queryByText('Hello World from middleware') ).toBeNull(); expect(screen.queryByText('mutating')).not.toBeNull(); }); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect( // We could expect 'Hello World from middleware' here, but // updateMany's result only contains the ids, not the updated data // so the cache can only be updated with the call-time params, // which do not include the middleware's result. // I guess it's OK for most cases though... screen.queryByText('Hello World') ).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); screen.getByText('Refetch').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect( screen.queryByText('Hello World from middleware') ).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); }); it('when pessimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render( ); await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); expect(screen.queryByText('something went wrong')).toBeNull(); expect( screen.queryByText('Hello World from middleware') ).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 World from middleware') ).toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); }); it('when optimistic, it accepts middlewares and displays result and success side effects right away', async () => { render(); await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect( screen.queryByText('Hello World from middleware') ).not.toBeNull(); }); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect( screen.queryByText('Hello World from middleware') ).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); }); it('when optimistic, it accepts middlewares and displays error and error side effects when dataProvider promise rejects', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render( ); await screen.findByText('Hello'); screen.getByText('Update title').click(); 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('success')).toBeNull(); expect( screen.queryByText('something went wrong') ).not.toBeNull(); expect( screen.queryByText('Hello World from middleware') ).toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); await screen.findByText('Hello'); }); it('when undoable, it accepts middlewares and displays result and success side effects right away and fetched on confirm', async () => { render(); await screen.findByText('Hello'); act(() => { screen.getByText('Update title').click(); }); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('Hello World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); act(() => { screen.getByText('Confirm').click(); }); 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(); }, { timeout: 4000 } ); expect(screen.queryByText('success')).not.toBeNull(); expect( screen.queryByText('Hello World from middleware') ).not.toBeNull(); }); it('when undoable, it accepts middlewares and displays result and success side effects right away and reverts on cancel', async () => { render(); await screen.findByText('Hello'); act(() => { screen.getByText('Update title').click(); }); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('Hello World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); act(() => { screen.getByText('Cancel').click(); }); await waitFor(() => { expect(screen.queryByText('Hello World')).toBeNull(); }); expect(screen.queryByText('mutating')).toBeNull(); await screen.findByText('Hello'); }); it('when undoable, it accepts middlewares and displays result and success side effects right away and reverts on error', async () => { jest.spyOn(console, 'error').mockImplementation(() => {}); render( ); await screen.findByText('Hello'); screen.getByText('Update title').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('Hello World')).not.toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); screen.getByText('Confirm').click(); await waitFor(() => { expect(screen.queryByText('success')).not.toBeNull(); expect(screen.queryByText('Hello World')).not.toBeNull(); expect(screen.queryByText('mutating')).not.toBeNull(); }); await screen.findByText('Hello', undefined, { timeout: 4000 }); await waitFor(() => { expect(screen.queryByText('success')).toBeNull(); expect( screen.queryByText('Hello World from middleware') ).toBeNull(); expect(screen.queryByText('mutating')).toBeNull(); }); }); }); }); afterEach(() => { jest.restoreAllMocks(); });