diff --git a/docs/api/cozy-client/README.md b/docs/api/cozy-client/README.md index c690fec65..c5c6ec5f6 100644 --- a/docs/api/cozy-client/README.md +++ b/docs/api/cozy-client/README.md @@ -6,6 +6,7 @@ cozy-client * [manifest](modules/manifest.md) * [models](modules/models.md) +* [useMutation](modules/useMutation.md) ## Classes @@ -107,6 +108,40 @@ Use those fetch policies with `` to limit the number of re-fetch. [packages/cozy-client/src/policies.js:11](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/policies.js#L11) +*** + +### useMutation + +• `Const` **useMutation**: `Object` + +#### Call signature + +▸ (`__namedParameters`): `UseMutationReturnValue` + +This hook manages the state during the saving of a document + +*Parameters* + +| Name | Type | +| :------ | :------ | +| `__namedParameters` | `Object` | + +*Returns* + +`UseMutationReturnValue` + +*Type declaration* + +| Name | Type | +| :------ | :------ | +| `propTypes` | { `onError`: `Requireable`<(...`args`: `any`\[]) => `any`> = PropTypes.func; `onSuccess`: `Requireable`<(...`args`: `any`\[]) => `any`> = PropTypes.func } | +| `propTypes.onError` | `Requireable`<(...`args`: `any`\[]) => `any`> | +| `propTypes.onSuccess` | `Requireable`<(...`args`: `any`\[]) => `any`> | + +*Defined in* + +[packages/cozy-client/src/hooks/useMutation.jsx:11](https://github.com/cozy/cozy-client/blob/master/packages/cozy-client/src/hooks/useMutation.jsx#L11) + ## Functions ### Q diff --git a/docs/api/cozy-client/modules/useMutation.md b/docs/api/cozy-client/modules/useMutation.md new file mode 100644 index 000000000..e2bf1b604 --- /dev/null +++ b/docs/api/cozy-client/modules/useMutation.md @@ -0,0 +1,16 @@ +[cozy-client](../README.md) / useMutation + +# Namespace: useMutation + +## Variables + +### propTypes + +• **propTypes**: `Object` + +*Type declaration* + +| Name | Type | +| :------ | :------ | +| `onError` | `Requireable`<(...`args`: `any`\[]) => `any`> | +| `onSuccess` | `Requireable`<(...`args`: `any`\[]) => `any`> | diff --git a/docs/react-integration.md b/docs/react-integration.md index 013f1dcbd..cc70a487a 100644 --- a/docs/react-integration.md +++ b/docs/react-integration.md @@ -10,11 +10,13 @@ Once connected, your components will receive the requesting data and a fetch sta - [1.a Initialize a CozyClient provider](#1a-initialize-a-cozyclient-provider) - [1.b Use your own Redux store](#1b-use-your-own-redux-store) - [2. Usage](#2-usage) - - [2.a Requesting data with ``](#2a-requesting-data-with-) + - [2.a Requesting data with `useQuery`](#2a-requesting-data-with-) - [2.b Requesting data with the `queryConnect` HOC](#2b-requesting-data-with-the-queryconnect-hoc) - [2.c Using a fetch policy to decrease network requests](#2c-using-a-fetch-policy-to-decrease-network-requests) - [2.d Keeping data up to date in real time](#2d-keeping-data-up-to-date-in-real-time) - [3. Mutating data](#3-mutating-data) + - [3.a Mutating data with `useMutation`](#3a-mutating-data-with-usemutation) + - [3.b Mutating data with `Query`](#3a-mutating-data-with-query) - [4. Testing](#4-testing) @@ -325,15 +327,15 @@ You subscribe to changes for an entire doctype using `RealTimeQueries`, and as l ### 3. Mutating data -The simplest way is to use the `withClient` high order component. It will inject a `client` in your props with the CozyClient instance you gave to the `` upper. +The simplest way is to use the hook `useClient` to get the CozyClient instance you gave to the `` upper. ```jsx -import { withClient } from 'cozy-client' +import { useClient } from 'cozy-client' function TodoList(props) { - const { client } = props + const client = useClient() const createNewTodo = e => client.create( - 'io.cozy.todos', + 'io.cozy.todos', { label: e.target.elements['new-todo'], checked: false } ) return ( @@ -349,10 +351,49 @@ function TodoList(props) { ) } +``` + +### 3.a Mutating data with `useMutation` + +We also provides a hook to manage `client.save` mutation state called `useMutation`. + +```jsx +import { useMutation } from 'cozy-client' + +function TodoLabelInlineEdit({ todo }) { + const [label, setLabel] = useState(todo.label) + const { mutate, mutationStatus } = useMutation() + + const handleChange = event => { + setLabel(event.target.value) + } -const ConnectedTodoList = withClient(TodoList) + const handleBlur = () => { + mutate({ + ...todo, + label + }) + } + + return ( +
+ + {mutationStatus === 'loaded' ? '✓' : null} + {mutationStatus === 'failed' ? '✗' : null} +
+ ) +} ``` +### 3.b Mutating data with `Query` + `` also takes a `mutations` optional props. It should have a function that will receive the CozyClient instance, the query requested and the rest of props given to the component, and should return a keyed object which will be added to the props of your wrapped component. ```jsx diff --git a/packages/cozy-client/src/hooks/index.js b/packages/cozy-client/src/hooks/index.js index 403582028..802b2ba23 100644 --- a/packages/cozy-client/src/hooks/index.js +++ b/packages/cozy-client/src/hooks/index.js @@ -7,3 +7,4 @@ export { default as useClient } from './useClient' export { default as useQuery } from './useQuery' export { default as useAppsInMaintenance } from './useAppsInMaintenance' export { default as useQueryAll } from './useQueryAll' +export { useMutation } from './useMutation' diff --git a/packages/cozy-client/src/hooks/useMutation.jsx b/packages/cozy-client/src/hooks/useMutation.jsx new file mode 100644 index 000000000..bb1dd44aa --- /dev/null +++ b/packages/cozy-client/src/hooks/useMutation.jsx @@ -0,0 +1,56 @@ +import { useState, useCallback } from 'react' +import PropTypes from 'prop-types' + +import useClient from './useClient' + +/** + * This hook manages the state during the saving of a document + * + * @returns {import("../types").UseMutationReturnValue} + */ +const useMutation = ({ onSuccess, onError }) => { + const client = useClient() + + /** @type {import("../types").useState} */ + const [mutationStatus, setMutationStatus] = useState('pending') + const [error, setError] = useState() + const [data, setData] = useState() + + const mutate = useCallback( + async doc => { + setError(undefined) + setMutationStatus('loading') + try { + const resp = await client.save(doc) + setData(resp.data) + if (typeof onSuccess === 'function') { + await onSuccess(resp.data) + } + setMutationStatus('loaded') + } catch (e) { + setMutationStatus('failed') + setError(e) + if (typeof onError === 'function') { + await onError(e) + } + } + }, + [client, onError, onSuccess] + ) + + return { + mutate, + mutationStatus, + error, + data + } +} + +useMutation.propTypes = { + /** This function is triggered when the save is successful */ + onSuccess: PropTypes.func, + /** This function is triggered when the save has failed */ + onError: PropTypes.func +} + +export { useMutation } diff --git a/packages/cozy-client/src/hooks/useMutation.spec.jsx b/packages/cozy-client/src/hooks/useMutation.spec.jsx new file mode 100644 index 000000000..bada9ed28 --- /dev/null +++ b/packages/cozy-client/src/hooks/useMutation.spec.jsx @@ -0,0 +1,81 @@ +import { renderHook, act } from '@testing-library/react-hooks' + +import { useMutation } from './useMutation' +import useClient from './useClient' + +jest.mock('./useClient') + +describe('useMutation', () => { + const setup = ({ onSuccess, onError, saveSpy = jest.fn() } = {}) => { + useClient.mockReturnValue({ + save: saveSpy + }) + + return renderHook(() => + useMutation({ + onSuccess, + onError + }) + ) + } + + it('should be in pending status', () => { + const { + result: { current } + } = setup() + expect(current.mutationStatus).toBe('pending') + }) + + it('should be in loading status after mutate', async () => { + const saveSpy = jest.fn().mockImplementation(() => new Promise(() => {})) + + const { result } = setup({ saveSpy }) + + await act(async () => { + result.current.mutate({ + doctype: 'io.cozy.simpsons' + }) + }) + + expect(result.current.mutationStatus).toBe('loading') + }) + + it('should be in loaded status after mutate', async () => { + const successSpy = jest.fn() + const saveSpy = jest.fn().mockResolvedValue({ data: 'test' }) + const { result } = setup({ + onSuccess: successSpy, + saveSpy + }) + + await act(async () => { + result.current.mutate({ + doctype: 'io.cozy.simpsons' + }) + }) + + expect(result.current.mutationStatus).toBe('loaded') + expect(saveSpy).toBeCalledTimes(1) + expect(successSpy).toBeCalledTimes(1) + expect(result.current.data).toBe('test') + }) + + it('should be in failed status after mutate', async () => { + const errorSpy = jest.fn() + const saveSpy = jest.fn().mockRejectedValue({ error: 'test' }) + const { result } = setup({ + onError: errorSpy, + saveSpy + }) + + await act(async () => { + result.current.mutate({ + doctype: 'io.cozy.simpsons' + }) + }) + + expect(result.current.mutationStatus).toBe('failed') + expect(errorSpy).toBeCalledTimes(1) + expect(result.current.error).toStrictEqual({ error: 'test' }) + }) +}) diff --git a/packages/cozy-client/src/types.js b/packages/cozy-client/src/types.js index 460ff8ce4..e89046aa8 100644 --- a/packages/cozy-client/src/types.js +++ b/packages/cozy-client/src/types.js @@ -201,6 +201,14 @@ import { QueryDefinition } from './queries/dsl' * @typedef {QueryState & FetchMoreAble & FetchAble} UseQueryReturnValue */ +/** + * @typedef {object} UseMutationReturnValue + * @property {Function} mutate - Function to save the document + * @property {QueryFetchStatus} mutationStatus - Status of the current mutation + * @property {object} [error] - Error if the mutation failed + * @property {object} [data] - Data return after the mutation + */ + /** * A reference to a document * @@ -534,4 +542,11 @@ import { QueryDefinition } from './queries/dsl' * @property {string} hash - The redirect link's path (i.e. '/folder/SOME_FOLDER_ID') */ +/** + * Template to type useState + * + * @template T + * @typedef {[T, import('react').Dispatch>]} useState + */ + export default {} diff --git a/packages/cozy-client/types/hooks/index.d.ts b/packages/cozy-client/types/hooks/index.d.ts index d4e33fc65..3018f36c6 100644 --- a/packages/cozy-client/types/hooks/index.d.ts +++ b/packages/cozy-client/types/hooks/index.d.ts @@ -5,3 +5,4 @@ export { default as useClient } from "./useClient"; export { default as useQuery } from "./useQuery"; export { default as useAppsInMaintenance } from "./useAppsInMaintenance"; export { default as useQueryAll } from "./useQueryAll"; +export { useMutation } from "./useMutation"; diff --git a/packages/cozy-client/types/hooks/useMutation.d.ts b/packages/cozy-client/types/hooks/useMutation.d.ts new file mode 100644 index 000000000..ec92ce39a --- /dev/null +++ b/packages/cozy-client/types/hooks/useMutation.d.ts @@ -0,0 +1,16 @@ +/** + * This hook manages the state during the saving of a document + * + * @returns {import("../types").UseMutationReturnValue} + */ +export function useMutation({ onSuccess, onError }: { + onSuccess: any; + onError: any; +}): import("../types").UseMutationReturnValue; +export namespace useMutation { + namespace propTypes { + const onSuccess: PropTypes.Requireable<(...args: any[]) => any>; + const onError: PropTypes.Requireable<(...args: any[]) => any>; + } +} +import PropTypes from "prop-types"; diff --git a/packages/cozy-client/types/types.d.ts b/packages/cozy-client/types/types.d.ts index a87ae742a..8c150d68f 100644 --- a/packages/cozy-client/types/types.d.ts +++ b/packages/cozy-client/types/types.d.ts @@ -365,6 +365,24 @@ export type FetchAble = { fetch: Function; }; export type UseQueryReturnValue = QueryState & FetchMoreAble & FetchAble; +export type UseMutationReturnValue = { + /** + * - Function to save the document + */ + mutate: Function; + /** + * - Status of the current mutation + */ + mutationStatus: QueryFetchStatus; + /** + * - Error if the mutation failed + */ + error?: object; + /** + * - Data return after the mutation + */ + data?: object; +}; /** * A reference to a document */ @@ -879,4 +897,8 @@ export type RedirectLinkData = { */ hash: string; }; +/** + * Template to type useState + */ +export type useState = [T, import("react").Dispatch>]; import { QueryDefinition } from "./queries/dsl";