Skip to content

Commit

Permalink
feat: Add useMutation hook
Browse files Browse the repository at this point in the history
  • Loading branch information
cballevre committed Sep 27, 2023
1 parent 407e454 commit 58b1afb
Show file tree
Hide file tree
Showing 10 changed files with 290 additions and 6 deletions.
35 changes: 35 additions & 0 deletions docs/api/cozy-client/README.md
Expand Up @@ -6,6 +6,7 @@ cozy-client

* [manifest](modules/manifest.md)
* [models](modules/models.md)
* [useMutation](modules/useMutation.md)

## Classes

Expand Down Expand Up @@ -107,6 +108,40 @@ Use those fetch policies with `<Query />` 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
Expand Down
16 changes: 16 additions & 0 deletions 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`> |
53 changes: 47 additions & 6 deletions docs/react-integration.md
Expand Up @@ -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)

<!-- /MarkdownTOC -->
Expand Down Expand Up @@ -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 `<CozyProvider />` upper.
The simplest way is to use the hook `useClient` to get the CozyClient instance you gave to the `<CozyProvider />` 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 (
Expand All @@ -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 (
<div style={{ display: 'flex' }}>
<input
type="text"
aria-label="Label"
style={{ marginRight: '1rem' }}
value={label}
onChange={handleChange}
onBlur={handleBlur}
/>
{mutationStatus === 'loaded' ? '' : null}
{mutationStatus === 'failed' ? '' : null}
</div>
)
}
```

### 3.b Mutating data with `Query`

`<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
Expand Down
1 change: 1 addition & 0 deletions packages/cozy-client/src/hooks/index.js
Expand Up @@ -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'
56 changes: 56 additions & 0 deletions 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<import("../types").QueryFetchStatus>} */
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 }
81 changes: 81 additions & 0 deletions 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' })
})
})
15 changes: 15 additions & 0 deletions packages/cozy-client/src/types.js
Expand Up @@ -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
*
Expand Down Expand Up @@ -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<import('react').SetStateAction<T>>]} useState
*/

export default {}
1 change: 1 addition & 0 deletions packages/cozy-client/types/hooks/index.d.ts
Expand Up @@ -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";
16 changes: 16 additions & 0 deletions 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";
22 changes: 22 additions & 0 deletions packages/cozy-client/types/types.d.ts
Expand Up @@ -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
*/
Expand Down Expand Up @@ -879,4 +897,8 @@ export type RedirectLinkData = {
*/
hash: string;
};
/**
* Template to type useState
*/
export type useState<T> = [T, import("react").Dispatch<import("react").SetStateAction<T>>];
import { QueryDefinition } from "./queries/dsl";

0 comments on commit 58b1afb

Please sign in to comment.