Skip to content

Latest commit

 

History

History
515 lines (398 loc) · 15.3 KB

react-integration.md

File metadata and controls

515 lines (398 loc) · 15.3 KB

What is the Cozy-Client React Integration?

In addition to the Javascript client, Cozy-Client provides a way to connect directly the data from your Cozy instance to your React components.

Once connected, your components will receive the requesting data and a fetch status in their props. The data will automatically be refreshed upon modification.

1. Setup

The following procedure requires you to already know how to initialize a CozyClient instance and create a query to fetch documents.

⚠️ The fromDOM instantiation method assumes the page you use is served by the Cozy Stack, and that there is a DOM node with the stack info. See how the application works for more information.

<div role='application' data-cozy='{{ .CozyData }}' />

1.a Initialize a CozyClient provider

Import CozyClient and CozyProvider

import CozyClient, { CozyProvider } from 'cozy-client'

Initialize a CozyClient instance (see the CozyClient() documentation for additional parameters, for example to provide a schema)

const client = CozyClient.fromDOM()

Then wrap you React application inside a CozyProvider component with your newly instance of CozyClient in its props as client.

function MyCozyApp(props) {
  return <CozyProvider client={client}>
    <MyApp {...props} />
  </CozyProvider>
}

ReactDOM.render(<MyCozyApp />,
  document.getElementById('main')
)

The CozyClient will be available for consumption to all components inside your wrapped application.

1.b Use your own Redux store

cozy-client uses redux internally to centralize the statuses of the various fetches and replications triggered by the library, and to store locally the data in a normalized way. If you already have a redux store in your app, you can configure cozy-client to use this existing store:

import CozyClient, { CozyProvider } from 'cozy-client'
import { combineReducers, createStore, applyMiddleware } from 'redux'

const client = CozyClient.fromDOM({
  store: false // Important, otherwise a default store is created
})

const store = createStore(
  combineReducers({ ...myReducers, cozy: client.reducer() }),
  applyMiddleware(myMiddleware)
)

function MyCozyApp(props) {
  return <CozyProvider client={client} store={store}>
    <MyApp {...props} />
  </CozyProvider>
}

ReactDOM.render(<MyCozyApp />,
  document.getElementById('main')
)

2. Usage

2.a Requesting data with useQuery

useQuery is the most straightforward to fetch data from the Cozy. Query results are cached and can be reused across components, you just have to name the query results ("checked-todos") below.

import CozyClient, { useQuery, isQueryLoading, Q } from 'cozy-client'

const client = CozyClient.fromDOM()
client.ensureStore()
const todos = 'io.cozy.todos'
const checked = { checked: false }

// Use the Q helper to build queries
const queryDefinition = Q(todos).where(checked)

function TodoList(props) {
  const queryResult = useQuery(queryDefinition, {
    as: 'checked-todos',
    fetchPolicy: CozyClient.fetchPolicies.olderThan(30 * 1000)
  })
  return <>
    {
      queryResult => isQueryLoading(queryResult)
        ? <h1>Loading...</h1>
        : <ul>{queryResult.data.map(todo => <li>{todo.label}</li>)}</ul>
    }
  </>
}

function App() {
  return <CozyProvider client={client}>
    <TodoList />
  </CozyProvider>
}

useQuery also supports a "disabled" parameter: if set to false the query won't be executed. This is useful when you have dependent queries that rely on the results of another query to have been fetched.

2.b Requesting data with <Query />

If you cannot use a hook, you can use the Query render-prop component. Basic example of usage:

import { Query, isQueryLoading, Q } from 'cozy-client'
import React from 'react'

const todos = 'io.cozy.todos'
const checked = { checked: false }

// Use the Q helper to build queries
const queryDefinition = Q(todos).where(checked)

const TodoList = () => (
  <Query query={queryDefinition}>
    {queryResult => isQueryLoading(queryResult)
      ? <h1>Loading...</h1>
      : <ul>{queryResult.data.map(todo => <li>{todo.label}</li>)}</ul>
    }
  </Query>
)

When we use Query to "wrap" a component, three things happen:

  • The query passed as a prop will be executed when Query mounts, resulting in the loading of data from the local Pouchdb (if any) or from the server; in the future it may load the data directly from the client-side store;
  • Query subscribes to the store, so that it is updated if the data changes;
  • Query passes the result of the query as props to the children function.

The following props will be given to your wrapped component:

  • data: an array of documents
  • fetchStatus: the status of the fetch (pending, loading, loaded or error)
  • lastFetch: when the last fetch occurred
  • hasMore: the fetches being paginated, this property indicates if there are more documents to load

You can also pass a function instead of a direct query definition. Your function will be given the props of the component and should return the requested query definition:

import { Query, Q } from 'cozy-client'
import React from 'react'

const queryDefinition = function(props) {
  const todos = 'io.cozy.todos'
  const where = { checked: props.checked }
  return Q(todos).where(where)
}

const TodoList = ({ props }) => (
  <Query query={queryDefinition} checked={props.checked}>
    {({ data, fetchStatus }) => (
      fetchStatus !== 'loaded'
        ? <h1>Loading...</h1>
        : <ul>{data.map(todo => <li>{todo.label}</li>)}</ul>
      )
    }
  </Query>
)

// use <TodoList checked={false}/>

Note: Your query definition will be bound when the <Query/> component mounts. Future changes in props will not modify the query.

2.c Requesting data with the queryConnect HOC

At your preference, you can use a higher-order component. queryConnect will take the name of the props field where it should send the result of the query and the actual query:

import { queryConnect, Q } from 'cozy-client'

function TodoList(props) {
  const { data, fetchStatus } = props.result
  if (fetchStatus !== 'loaded') {
    return <h1>Loading...</h1>
  } else {
    return <ul>{data.map(todo => <li>{todo.label}</li>)}</ul>
  }
}

const ConnectedTodoList = queryConnect({
  result: {
     query: Q('io.cozy.todos').where({ checked: false })
  }
})(TodoList)

queryConnect will use <Query /> internally, so you will inherit the same behaviour.

With the same syntax, you may register multiple queries to run:

import { queryConnect, Q } from 'cozy-client'

function TodoList(props) {
  const { data, fetchStatus } = props.checked
  if (fetchStatus !== 'loaded') {
    return <h1>Loading...</h1>
  } else {
    return <ul>{data.map(todo => <li>{todo.label}</li>)}</ul>
  }
}

const todos = 'io.cozy.todos'
const checked = { query: Q(todos).where({ checked: false }) }
const archived = { query: Q(todos).where({ archive: false }) }

const queries = { checked, archived }

const ConnectedTodoList = queryConnect(queries)(TodoList)

2.d Using a fetch policy to decrease network requests

When multiple components share the same data, you might want to share the data between the two components. In this case, you do not want to execute 2 network requests for the same data, the two components should share the same data and only 1 network request should be executed.

There are two solutions:

  1. Lift the data fetching up the React tree: make a parent of the two components be the responsible for the data.
  2. Using fetch policies to prevent re-fetching of the data

1 can be a good solution but sometimes the components needing the data are too far down the tree and/or too decoupled; it might not make sense to lift the data fetching up only to have to drill the data down the tree again.

Using useQuery, Query and queryConnect, you can declare a fetch policy. It tells cozy-client if it should execute the query at componentDidMount time. It enables N components to be connected to the same query without triggering N requests.

A fetch policy is a function receiving the state of the current query and returning true if it should be fetched and false otherwise. It is important to name the query with as when using fetch policies otherwise query data cannot be shared across components.

import { Q } from 'cozy-client'
const queryDefinition = () => Q('io.cozy.todos')

const MyComponent = () => {
  
  // io.cozy.todos will not be refetched if there are already io.cozy.todos
  // in the store and the data is fresh (updated less than 30s ago). 
  const fetchPolicy = CozyClient.olderThan(30*1000)
  const as = 'my-query'

  // With Query and a render prop
  return (<Query as={as} query={queryDefinition} fetchPolicy={fetchPolicy}>{
    ({ data: todos }) => {<TodoList todos={todos} />}
  }</Query>)
}

// With queryConnect
queryConnect({
  todos: {
    as,
    query,
    fetchPolicy
  }
})(TodoList)

See CozyClient::fetchPolicies for the API documentation.

2.e Keeping data up to date in real time

Sometimes the data you are displaying will be changed from other places than your app. Maybe the data is shared and someone else has updated it, or maybe it simply changes over time.

You can however keep your UI always up to date without constantly re-running your queries, by subscribing to changes in real time. This is done with the RealTimeQueries component:

import { RealTimeQueries } from 'cozy-client'

function MyParentComponent(props) {
  return (
    <>
      <ConnectedTodoList />
      <RealTimeQueries doctype="io.cozy.todos" />
    </>
  )
}

You subscribe to changes for an entire doctype using RealTimeQueries, and as long as that component is rendered all documents from the given doctype in your queries will automatically stay up to date.

3. Mutating data

The simplest way is to use the hook useClient to get the CozyClient instance you gave to the <CozyProvider /> upper.

import { useClient } from 'cozy-client'

function TodoList(props) {
  const client = useClient()
  const createNewTodo = e => client.create(
    'io.cozy.todos',
    { label: e.target.elements['new-todo'], checked: false }
  )
  return (
    <>
      <ul>
        {/* todo items */}
      </ul>
      <form onSubmit={createNewTodo}>
        <label htmlFor="new-todo">Todo</label>
        <input id="new-todo" name="new-todo" />
        <button type="submit">Add todo</button>
      </form>
    </>
  )
}

3.a Mutating data with useMutation

We also provides a hook to manage client.save mutation state called useMutation.

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 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.

import { Query } from 'cozy-client'

const todos = 'io.cozy.todos'
const checked = { checked: false }
const queryDefinition = Q(todos).where(checked)
const mutations = client => ({ createDocument: client.create.bind(client) })

function TodoList() {
  return <Query query={queryDefinition} mutations={mutations}>
    {
      ({ data, fetchStatus }) =>
      fetchStatus !== 'loaded'
        ? <h1>Loading...</h1>
        : <ul>{data.map(todo => <li>{todo.label}</li>)}</ul>
    }
  </Query>
}

4. Testing

When testing, it is useful to prefill the client with data and mock the network. You can use createMockClient for this.

Say we want to test the following component:

import React from 'react'
import { useQuery, Q } from 'cozy-client'

function TodoList() {
  const { data, fetchStatus, lastError } = useQuery(Q('io.cozy.todos'), {
    as: 'todos'
  })

  if (fetchStatus === 'failed') {
    return <h1>An error occurred : {lastError.message}</h1>
  } else if (fetchStatus !== 'loaded') {
    return <h1>Loading...</h1>
  } else {
    return (
      <>
        <h1 id="todos-heading">Todos</h1>
        <ul aria-labelledby="todos-heading">
          {data.map(todo => (
            <li key={todo.id}>{todo.label}</li>
          ))}
        </ul>
      </>
    )
  }
}

export default TodoList

We want to make sure the component renders correctly. In our test, we can create a mocked client with predefined data for the todos query, and test it with testing-library :

import React from 'react'
import { createMockClient, CozyProvider } from 'cozy-client'
import { render, screen, within } from '@testing-library/react'
import TodoList from './TodoList'

describe('TodoList', () => {
  const setup = client => {
    return render(
      <CozyProvider client={client}>
        <TodoList />
      </CozyProvider>
    )
  }

  it('should show the todos', () => {
    const mockClient = createMockClient({
      queries: {
        todos: {
          doctype: 'io.cozy.todos',
          data: [
            { id: 'todo1', name: 'Write tests', done: true },
            { id: 'todo2', name: 'Write code', done: true },
            { id: 'todo3', name: 'Take breaks', done: true },
            { id: 'todo4', name: 'Write documentation', done: false }
          ]
        }
      }
    })

    setup(mockClient)

    const list = screen.getByRole('list', {
      name: /todos/i
    })
    const { getAllByRole } = within(list)
    const items = getAllByRole('listitem')
    expect(items.length).toBe(4)
  })

  it('should display the error message', () => {
    const mockClient = createMockClient({
      queries: {
        todos: {
          doctype: 'io.cozy.todos',
          queryError: new Error('Network error')
        }
      }
    })

    setup(mockClient)

    expect(screen.getByText(/network error/i)).toBeDefined()
  })
})