Skip to content

Commit

Permalink
Merge pull request #5395 from marmelab/relax-dataprovider-signature
Browse files Browse the repository at this point in the history
Relax useDataProvider signature to ease custom methods usage
  • Loading branch information
fzaninotto committed Oct 22, 2020
2 parents 235c42f + 6eeae13 commit d7cb19e
Show file tree
Hide file tree
Showing 10 changed files with 319 additions and 84 deletions.
6 changes: 4 additions & 2 deletions packages/ra-core/src/dataProvider/Mutation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ interface Props {
params: ChildrenFuncParams
) => JSX.Element;
type: string;
resource: string;
resource?: string;
payload?: any;
options?: any;
}
Expand Down Expand Up @@ -57,7 +57,9 @@ const Mutation: FunctionComponent<Props> = ({
type,
resource,
payload,
options,
// Provides an undefined onSuccess just so the key `onSuccess` is defined
// This is used to detect options in useDataProvider
options = { onSuccess: undefined },
}) =>
children(
...useMutation(
Expand Down
19 changes: 19 additions & 0 deletions packages/ra-core/src/dataProvider/Query.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -564,4 +564,23 @@ describe('Query', () => {
meta: { resource: 'foo' },
});
});

it('should allow custom dataProvider methods without resource', () => {
const dataProvider = {
mytype: jest.fn(() => Promise.resolve({ data: { foo: 'bar' } })),
};

const myPayload = {};
const { getByText, dispatch } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<Query type="mytype" payload={myPayload}>
{({}) => <div />}
</Query>
</DataProviderContext.Provider>
);
const action = dispatch.mock.calls[0][0];
expect(action.type).toEqual('CUSTOM_FETCH');
expect(action.meta.resource).toBeUndefined();
expect(dataProvider.mytype).toHaveBeenCalledWith(myPayload);
});
});
6 changes: 4 additions & 2 deletions packages/ra-core/src/dataProvider/Query.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface ChildrenFuncParams {
interface Props {
children: (params: ChildrenFuncParams) => JSX.Element;
type: string;
resource: string;
resource?: string;
payload?: any;
options?: any;
}
Expand Down Expand Up @@ -71,7 +71,9 @@ const Query: FunctionComponent<Props> = ({
type,
resource,
payload,
options,
// Provides an undefined onSuccess just so the key `onSuccess` is defined
// This is used to detect options in useDataProvider
options = { onSuccess: undefined },
}) =>
children(
useQuery(
Expand Down
46 changes: 46 additions & 0 deletions packages/ra-core/src/dataProvider/getDataProviderCallArguments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { UseDataProviderOptions } from '../types';

// List of properties we expect in the options
const OptionsProperties = [
'action',
'fetch',
'meta',
'onFailure',
'onSuccess',
'undoable',
];

const isDataProviderOptions = (value: any) => {
let options = value as UseDataProviderOptions;

return Object.keys(options).some(key => OptionsProperties.includes(key));
};

// As all dataProvider methods do not have the same signature, we must differentiate
// standard methods which have the (resource, params, options) signature
// from the custom ones
export const getDataProviderCallArguments = (args: any[]) => {
const lastArg = args[args.length - 1];
let allArguments = [...args];

let resource;
let payload;
let options;

if (isDataProviderOptions(lastArg)) {
options = lastArg as UseDataProviderOptions;
allArguments = allArguments.slice(0, args.length - 1);
}

if (typeof allArguments[0] === 'string') {
resource = allArguments[0];
payload = allArguments[1];
}

return {
resource,
payload,
allArguments,
options,
};
};
155 changes: 118 additions & 37 deletions packages/ra-core/src/dataProvider/useDataProvider.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ const UseGetOne = () => {
return <div data-testid="loading">loading</div>;
};

const UseCustomVerb = ({ onSuccess }) => {
const [data, setData] = useState();
const [error, setError] = useState();
const dataProvider = useDataProvider();
useEffect(() => {
dataProvider
.customVerb({ id: 1 }, ['something'], { onSuccess })
.then(res => setData(res.data))
.catch(e => setError(e));
}, [dataProvider, onSuccess]);
if (error) return <div data-testid="error">{error.message}</div>;
if (data) return <div data-testid="data">{JSON.stringify(data)}</div>;
return <div data-testid="loading">loading</div>;
};

const UseCustomVerbWithStandardSignature = ({ onSuccess }) => {
const [data, setData] = useState();
const [error, setError] = useState();
const dataProvider = useDataProvider();
useEffect(() => {
dataProvider
.customVerb('posts', { id: 1 }, { onSuccess })
.then(res => setData(res.data))
.catch(e => setError(e));
}, [dataProvider, onSuccess]);
if (error) return <div data-testid="error">{error.message}</div>;
if (data) return <div data-testid="data">{JSON.stringify(data)}</div>;
return <div data-testid="loading">loading</div>;
};

describe('useDataProvider', () => {
afterEach(cleanup);

Expand Down Expand Up @@ -107,6 +137,57 @@ describe('useDataProvider', () => {
expect(dispatch.mock.calls[4][0].type).toBe('RA/FETCH_END');
});

it('should call custom verbs with standard signature (resource, payload, options)', async () => {
const onSuccess = jest.fn();
const customVerb = jest.fn(() => Promise.resolve({ data: null }));
const dataProvider = { customVerb };
renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UseCustomVerbWithStandardSignature onSuccess={onSuccess} />
</DataProviderContext.Provider>
);
// wait for the dataProvider to return
await act(async () => {
await new Promise(resolve => setTimeout(resolve));
});

expect(customVerb).toHaveBeenCalledWith('posts', { id: 1 });
});

it('should accept custom arguments for custom verbs', async () => {
const customVerb = jest.fn(() => Promise.resolve({ data: null }));
const dataProvider = { customVerb };
renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UseCustomVerb />
</DataProviderContext.Provider>
);
// wait for the dataProvider to return
await act(async () => {
await new Promise(resolve => setTimeout(resolve));
});

expect(customVerb).toHaveBeenCalledWith({ id: 1 }, ['something']);
});

it('should accept custom arguments for custom verbs and allow options', async () => {
const onSuccess = jest.fn();
const customVerb = jest.fn(() => Promise.resolve({ data: null }));
const dataProvider = { customVerb };
renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UseCustomVerb onSuccess={onSuccess} />
</DataProviderContext.Provider>
);
// wait for the dataProvider to return
await act(async () => {
await new Promise(resolve => setTimeout(resolve));
});

expect(customVerb).toHaveBeenCalledWith({ id: 1 }, ['something']);
expect(onSuccess).toHaveBeenCalledWith({ data: null });
});

describe('options', () => {
it('should accept an action option to dispatch a custom action', async () => {
const UseGetOneWithCustomAction = () => {
Expand Down Expand Up @@ -319,44 +400,44 @@ describe('useDataProvider', () => {
await act(async () => await new Promise(r => setTimeout(r)));
expect(getOne).toBeCalledTimes(2);
});
});

it('should not use the cache after an update', async () => {
const getOne = jest.fn(() => {
const validUntil = new Date();
validUntil.setTime(validUntil.getTime() + 1000);
return Promise.resolve({ data: { id: 1 }, validUntil });
});
const dataProvider = {
getOne,
update: () => Promise.resolve({ data: { id: 1, foo: 'bar' } }),
};
const Update = () => {
const [update] = useUpdate('posts', 1, { foo: 'bar ' });
return <button onClick={() => update()}>update</button>;
};
const { getByText, rerender } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UseGetOne key="1" />
<Update />
</DataProviderContext.Provider>,
{ admin: { resources: { posts: { data: {}, list: {} } } } }
);
// wait for the dataProvider to return
await act(async () => await new Promise(r => setTimeout(r)));
expect(getOne).toBeCalledTimes(1);
// click on the update button
await act(async () => {
fireEvent.click(getByText('update'));
await new Promise(r => setTimeout(r));
it('should not use the cache after an update', async () => {
const getOne = jest.fn(() => {
const validUntil = new Date();
validUntil.setTime(validUntil.getTime() + 1000);
return Promise.resolve({ data: { id: 1 }, validUntil });
});
const dataProvider = {
getOne,
update: () => Promise.resolve({ data: { id: 1, foo: 'bar' } }),
};
const Update = () => {
const [update] = useUpdate('posts', 1, { foo: 'bar ' });
return <button onClick={() => update()}>update</button>;
};
const { getByText, rerender } = renderWithRedux(
<DataProviderContext.Provider value={dataProvider}>
<UseGetOne key="1" />
<Update />
</DataProviderContext.Provider>,
{ admin: { resources: { posts: { data: {}, list: {} } } } }
);
// wait for the dataProvider to return
await act(async () => await new Promise(r => setTimeout(r)));
expect(getOne).toBeCalledTimes(1);
// click on the update button
await act(async () => {
fireEvent.click(getByText('update'));
await new Promise(r => setTimeout(r));
});
rerender(
<DataProviderContext.Provider value={dataProvider}>
<UseGetOne key="2" />
</DataProviderContext.Provider>
);
// wait for the dataProvider to return
await act(async () => await new Promise(r => setTimeout(r)));
expect(getOne).toBeCalledTimes(2);
});
rerender(
<DataProviderContext.Provider value={dataProvider}>
<UseGetOne key="2" />
</DataProviderContext.Provider>
);
// wait for the dataProvider to return
await act(async () => await new Promise(r => setTimeout(r)));
expect(getOne).toBeCalledTimes(2);
});
});

0 comments on commit d7cb19e

Please sign in to comment.