diff --git a/docs/useSubscribeToRecordList.md b/docs/useSubscribeToRecordList.md index 4131eb10713..308a9c46bae 100644 --- a/docs/useSubscribeToRecordList.md +++ b/docs/useSubscribeToRecordList.md @@ -26,25 +26,25 @@ import { useNotify, useListContext } from 'react-admin'; import { useSubscribeToRecordList } from '@react-admin/ra-realtime'; const ListWatcher = () => { - const notity = useNotify(); + const notify = useNotify(); const { refetch, data } = useListContext(); useSubscribeToRecordList(event => { switch (event.type) { case 'created': { - notity('New movie created'); + notify('New movie created'); refetch(); break; } case 'updated': { if (data.find(record => record.id === event.payload.ids[0])) { - notity(`Movie #${event.payload.ids[0]} updated`); + notify(`Movie #${event.payload.ids[0]} updated`); refetch(); } break; } case 'deleted': { if (data.find(record => record.id === event.payload.ids[0])) { - notity(`Movie #${event.payload.ids[0]} deleted`); + notify(`Movie #${event.payload.ids[0]} deleted`); refetch(); } break; @@ -80,25 +80,25 @@ const MovieList = () => ( Whenever an event is published on the `resource/[resource]` topic, the function passed as the first argument will be called with the event as a parameter. ```jsx -const notity = useNotify(); +const notify = useNotify(); const { refetch, data } = useListContext(); useSubscribeToRecordList(event => { switch (event.type) { case 'created': { - notity('New movie created'); + notify('New movie created'); refetch(); break; } case 'updated': { if (data.find(record => record.id === event.payload.ids[0])) { - notity(`Movie #${event.payload.ids[0]} updated`); + notify(`Movie #${event.payload.ids[0]} updated`); refetch(); } break; } case 'deleted': { if (data.find(record => record.id === event.payload.ids[0])) { - notity(`Movie #${event.payload.ids[0]} deleted`); + notify(`Movie #${event.payload.ids[0]} deleted`); refetch(); } break; @@ -110,33 +110,33 @@ useSubscribeToRecordList(event => { **Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions. ```jsx -const notity = useNotify(); +const notify = useNotify(); const { refetch, data } = useListContext(); const callback = useCallback( event => { switch (event.type) { case 'created': { - notity('New movie created'); + notify('New movie created'); refetch(); break; } case 'updated': { if (data.find(record => record.id === event.payload.ids[0])) { - notity(`Movie #${event.payload.ids[0]} updated`); + notify(`Movie #${event.payload.ids[0]} updated`); refetch(); } break; } case 'deleted': { if (data.find(record => record.id === event.payload.ids[0])) { - notity(`Movie #${event.payload.ids[0]} deleted`); + notify(`Movie #${event.payload.ids[0]} deleted`); refetch(); } break; } } }, - [data, refetch, notity] + [data, refetch, notify] ); useSubscribeToRecordList(callback); ``` diff --git a/docs_headless/astro.config.mjs b/docs_headless/astro.config.mjs index 303a69a391f..940197834de 100644 --- a/docs_headless/astro.config.mjs +++ b/docs_headless/astro.config.mjs @@ -106,6 +106,8 @@ export default defineConfig({ 'usepermissions', 'addrefreshauthtoauthprovider', 'addrefreshauthtodataprovider', + enterpriseEntry('canAccessWithPermissions'), + enterpriseEntry('getPermissionsFromRoles'), ], }, { @@ -203,6 +205,28 @@ export default defineConfig({ 'usegetrecordrepresentation', ], }, + { + label: 'Realtime', + items: [ + enterpriseEntry('usePublish'), + enterpriseEntry('useSubscribe'), + enterpriseEntry('useSubscribeCallback'), + enterpriseEntry('useSubscribeToRecord'), + enterpriseEntry('useSubscribeToRecordList'), + enterpriseEntry('useLock'), + enterpriseEntry('useUnlock'), + enterpriseEntry('useGetLock'), + enterpriseEntry('useGetLockLive'), + enterpriseEntry('useGetLocks'), + enterpriseEntry('useGetLocksLive'), + enterpriseEntry('useLockCallbacks'), + enterpriseEntry('useLockOnMount'), + enterpriseEntry('useLockOnCall'), + enterpriseEntry('useGetListLive'), + enterpriseEntry('useGetOneLive'), + enterpriseEntry(''), + ], + }, { label: 'Recipes', items: ['caching', 'unittesting'], @@ -240,3 +264,19 @@ export default defineConfig({ assets: 'assets', }, }); + +/** + * @param {string} name + * @returns {any} + */ +function enterpriseEntry(name) { + return { + link: `${name.toLowerCase().replace(//g, '')}/`, + label: name, + attrs: { class: 'enterprise' }, + badge: { + text: 'React Admin Enterprise', + variant: 'default', + }, + }; +} diff --git a/docs_headless/public/premium-dark.svg b/docs_headless/public/premium-dark.svg new file mode 100644 index 00000000000..44c86b7b6d6 --- /dev/null +++ b/docs_headless/public/premium-dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs_headless/public/premium-light.svg b/docs_headless/public/premium-light.svg new file mode 100644 index 00000000000..f3a80fe9b52 --- /dev/null +++ b/docs_headless/public/premium-light.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs_headless/src/content/docs/LockStatusBase.md b/docs_headless/src/content/docs/LockStatusBase.md new file mode 100644 index 00000000000..0f73f4ad023 --- /dev/null +++ b/docs_headless/src/content/docs/LockStatusBase.md @@ -0,0 +1,94 @@ +--- +title: "" +--- + +`` displays the lock status of the current record. It allows to visually indicate whether the record is locked or not, by the current user or not, and provides an easy way to lock or unlock the record. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +```tsx +import React from 'react'; +import { Lock, LockOpen, LoaderCircle } from 'lucide-react'; +import { LockStatusBase } from '@react-admin/ra-core-ee'; + +export const LockStatus = () => { + return ( + { + if (isPending) { + return null; + } + + if (lockStatus === 'lockedByUser') { + return ( + + ); + } + if (lockStatus === 'lockedByAnotherUser') { + return ( + + ); + } + if (lockStatus === 'unlocked') { + return ( + + ); + } + return null; + }} + /> + ); +}; +``` + +In addition to the [`useLockCallbacks`](./useLockCallbacks.md) parameters, `` accepts a `render` prop. The function passed to the `render` prop will be called with the result of the `useLockCallbacks` hook. \ No newline at end of file diff --git a/docs_headless/src/content/docs/canAccessWithPermissions.md b/docs_headless/src/content/docs/canAccessWithPermissions.md new file mode 100644 index 00000000000..c52857dee52 --- /dev/null +++ b/docs_headless/src/content/docs/canAccessWithPermissions.md @@ -0,0 +1,127 @@ +--- +title: "canAccessWithPermissions" +--- + +`canAccessWithPermissions` is a helper function that facilitates the implementation of [Access Control](./Permissions.md#access-control) policies based on an underlying list of user roles and permissions. + +It is a builder block to implement the `authProvider.canAccess()` method, which is called by ra-core to check whether the current user has the right to perform a given action on a given resource or record. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +`canAccessWithPermissions` is a pure function that you can call from your `authProvider.canAccess()` implementation. + +```tsx +import { canAccessWithPermissions } from '@react-admin/ra-core-ee'; + +const authProvider = { + // ... + canAccess: async ({ action, resource, record }) => { + const permissions = myGetPermissionsFunction(); + return canAccessWithPermissions({ + permissions, + action, + resource, + record, + }); + } + // ... +}; +``` + +The `permissions` parameter must be an array of permissions. A *permission* is an object that represents access to a subset of the application. It is defined by a `resource` (usually a noun) and an `action` (usually a verb), with sometimes an additional `record`. + +Here are a few examples of permissions: + +- `{ action: "*", resource: "*" }`: allow everything +- `{ action: "read", resource: "*" }`: allow read actions on all resources +- `{ action: "read", resource: ["companies", "people"] }`: allow read actions on a subset of resources +- `{ action: ["read", "create", "edit", "export"], resource: "companies" }`: allow all actions except delete on companies +- `{ action: ["write"], resource: "game.score", record: { "id": "123" } }`: allow write action on the score of the game with id 123 + +:::tip +When the `record` field is omitted, the permission is valid for all records. +::: + +In most cases, the permissions are derived from user roles, which are fetched at login and stored in memory or in localStorage. Check the [`getPermissionsFromRoles`](./getPermissionsFromRoles.md) function to merge the permissions from multiple roles into a single flat array of permissions. + +## Parameters + +This function takes an object as argument with the following fields: + +| Name | Optional | Type | Description +| - | - | - | - | +| `permissions` | Required | `Array` | An array of permissions for the current user +| `action` | Required | `string` | The action for which to check users has the execution right +| `resource` | Required | `string` | The resource for which to check users has the execution right +| `record` | Required | `string` | The record for which to check users has the execution right + +`canAccessWithPermissions` expects the `permissions` to be a flat array of permissions. It is your responsibility to fetch these permissions (usually during login). If the permissions are spread into several role definitions, you can merge them into a single array using the [`getPermissionsFromRoles`](./getPermissionsFromRoles.md) function. + +## Building RBAC + +The following example shows how to implement Role-based Access Control (RBAC) in `authProvider.canAccess()` using `canAccessWithPermissions` and `getPermissionsFromRoles`. The role permissions are defined in the code, and the user roles are returned by the authentication endpoint. Additional user permissions can also be returned by the authentication endpoint. + +The `authProvider` stores the permissions in `localStorage`, so that returning users can access their permissions without having to log in again. + +```tsx +// in roleDefinitions.ts +export const roleDefinitions = { + admin: [ + { action: '*', resource: '*' } + ], + reader: [ + { action: ['list', 'show', 'export'], resource: '*' }, + { action: 'read', resource: 'posts.*' }, + { action: 'read', resource: 'comments.*' }, + ], + accounting: [ + { action: '*', resource: 'sales' }, + ], +}; + +// in authProvider.ts +import { canAccessWithPermissions, getPermissionsFromRoles } from '@react-admin/ra-core-ee'; +import { roleDefinitions } from './roleDefinitions'; + +const authProvider = { + login: async ({ username, password }) => { + const request = new Request('https://mydomain.com/authenticate', { + method: 'POST', + body: JSON.stringify({ username, password }), + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + const response = await fetch(request); + if (response.status < 200 || response.status >= 300) { + throw new Error(response.statusText); + } + const { user: { roles, permissions }} = await response.json(); + // merge the permissions from the roles with the extra permissions + const permissions = getPermissionsFromRoles({ + roleDefinitions, + userPermissions, + userRoles + }); + localStorage.setItem('permissions', JSON.stringify(permissions)); + }, + canAccess: async ({ action, resource, record }) => { + const permissions = JSON.parse(localStorage.getItem('permissions')); + return canAccessWithPermissions({ + permissions, + action, + resource, + record, + }); + } + // ... +}; +``` diff --git a/docs_headless/src/content/docs/getPermissionsFromRoles.md b/docs_headless/src/content/docs/getPermissionsFromRoles.md new file mode 100644 index 00000000000..18ed28ae578 --- /dev/null +++ b/docs_headless/src/content/docs/getPermissionsFromRoles.md @@ -0,0 +1,127 @@ +--- +title: "getPermissionsFromRoles" +--- + +`getPermissionsFromRoles` returns an array of user permissions based on a role definition, a list of roles, and a list of user permissions. It merges the permissions defined in `roleDefinitions` for the current user's roles (`userRoles`) with the extra `userPermissions`. + +It is a builder block to implement the `authProvider.canAccess()` method, which is called by ra-core to check whether the current user has the right to perform a given action on a given resource or record. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +`getPermissionsFromRoles` takes a configuration object as argument containing the role definitions, the user roles, and the user permissions. + +It returns an array of permissions that can be passed to [`canAccessWithPermissions`](./canAccessWithPermissions.md). + +```ts +import { getPermissionsFromRoles } from '@react-admin/ra-core-ee'; + +// static role definitions (usually in the app code) +const roleDefinitions = { + admin: [ + { action: '*', resource: '*' }, + ], + reader: [ + { action: ['list', 'show', 'export'], resource: '*' }, + { action: 'read', resource: 'posts.*' }, + { action: 'read', resource: 'comments.*' }, + ], + accounting: [ + { action: '*', resource: 'sales' }, + ], +}; + +const permissions = getPermissionsFromRoles({ + roleDefinitions, + // roles of the current user (usually returned by the server upon login) + userRoles: ['reader'], + // extra permissions for the current user (usually returned by the server upon login) + userPermissions: [ + { action: 'list', resource: 'sales'}, + ], +}); +// permissions = [ +// { action: ['list', 'show', 'export'], resource: '*' }, +// { action: 'read', resource: 'posts.*' }, +// { action: 'read', resource: 'comments.*' }, +// { action: 'list', resource: 'sales' }, +// ]; +``` + +## Parameters + +This function takes an object as argument with the following fields: + +| Name | Optional | Type | Description +| - | - | - | - | +| `roleDefinitions` | Required | `Record` | A dictionary containing the role definition for each role +| `userRoles` | Optional | `Array` | An array of roles (admin, reader...) for the current user +| `userPermissions` | Optional | `Array` | An array of permissions for the current user + +## Building RBAC + +The following example shows how to implement Role-based Access Control (RBAC) in `authProvider.canAccess()` using `canAccessWithPermissions` and `getPermissionsFromRoles`. The role permissions are defined in the code, and the user roles are returned by the authentication endpoint. Additional user permissions can also be returned by the authentication endpoint. + +The `authProvider` stores the permissions in `localStorage`, so that returning users can access their permissions without having to log in again. + +```tsx +// in roleDefinitions.ts +export const roleDefinitions = { + admin: [ + { action: '*', resource: '*' }, + ], + reader: [ + { action: ['list', 'show', 'export'], resource: '*' }, + { action: 'read', resource: 'posts.*' }, + { action: 'read', resource: 'comments.*' }, + ], + accounting: [ + { action: '*', resource: 'sales' }, + ], +}; + +// in authProvider.ts +import { canAccessWithPermissions, getPermissionsFromRoles } from '@react-admin/ra-core-ee'; +import { roleDefinitions } from './roleDefinitions'; + +const authProvider = { + login: async ({ username, password }) => { + const request = new Request('https://mydomain.com/authenticate', { + method: 'POST', + body: JSON.stringify({ username, password }), + headers: new Headers({ 'Content-Type': 'application/json' }), + }); + const response = await fetch(request); + if (response.status < 200 || response.status >= 300) { + throw new Error(response.statusText); + } + const { user: { roles, permissions }} = await response.json(); + // merge the permissions from the roles with the extra permissions + const permissions = getPermissionsFromRoles({ + roleDefinitions, + userPermissions, + userRoles + }); + localStorage.setItem('permissions', JSON.stringify(permissions)); + }, + canAccess: async ({ action, resource, record }) => { + const permissions = JSON.parse(localStorage.getItem('permissions')); + return canAccessWithPermissions({ + permissions, + action, + resource, + record, + }); + } + // ... +}; +``` diff --git a/docs_headless/src/content/docs/useGetListLive.md b/docs_headless/src/content/docs/useGetListLive.md new file mode 100644 index 00000000000..5f2e6aef406 --- /dev/null +++ b/docs_headless/src/content/docs/useGetListLive.md @@ -0,0 +1,46 @@ +--- +title: "useGetListLive" +--- + +`useGetListLive` is an alternative to `useGetList` that subscribes to live updates on the record list. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +```tsx +import { useGetListLive } from '@react-admin/ra-core-ee'; + +const LatestNews = () => { + const { data, total, isLoading, error } = useGetListLive('posts', { + pagination: { page: 1, perPage: 10 }, + sort: { field: 'published_at', order: 'DESC' }, + }); + if (isLoading) { + return
Loading...
; + } + if (error) { + return

ERROR

; + } + + return ( +
    + {data.map(item => ( +
  • {item.title}
  • + ))} +
+ ); +}; +``` + +The hook will subscribe to live updates on the list of records (topic: `resource/[resource]`) and will refetch the list when a new record is created, or an existing record is updated or deleted. + +See the [useGetList](./useGetList.md) documentation for the full list of parameters and return type. \ No newline at end of file diff --git a/docs_headless/src/content/docs/useGetLock.md b/docs_headless/src/content/docs/useGetLock.md new file mode 100644 index 00000000000..00ecff38956 --- /dev/null +++ b/docs_headless/src/content/docs/useGetLock.md @@ -0,0 +1,54 @@ +--- +title: "useGetLock" +--- + +A hook that gets the lock status for a record. It calls `dataProvider.getLock()` on mount. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +Here is a form toolbar that displays the lock status of the current record: + +```tsx +const FormToolbar = () => { + const resource = useResourceContext(); + const record = useRecordContext(); + const { isLoading: identityLoading, identity } = useGetIdentity(); + const { isLoading: lockLoading, data: lock } = useGetLock(resource, { + id: record.id, + }); + + if (identityLoading || lockLoading) { + return null; + } + + const isLockedByOtherUser = lock?.identity !== identity.id; + + return ( +
+ + {isLockedByOtherUser && ( + + {`This record is locked by another user: ${lock?.identity}.`} + + )} +
+ ); +}; +``` + +## Parameters + +- `resource`: the resource name (e.g. `'posts'`) +- `params`: an object with the following properties: + - `id`: the record id (e.g. `123`) + - `meta`: Optional. an object that will be forwarded to the dataProvider (optional) diff --git a/docs_headless/src/content/docs/useGetLockLive.md b/docs_headless/src/content/docs/useGetLockLive.md new file mode 100644 index 00000000000..cf53fb71574 --- /dev/null +++ b/docs_headless/src/content/docs/useGetLockLive.md @@ -0,0 +1,39 @@ +--- +title: "useGetLockLive" +--- + +A hook that gets the lock status for a record in real time. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +Use the `useGetLockLive()` hook to get the lock status in real time. This hook calls `dataProvider.getLock()` for the current record on mount, and subscribes to live updates on the `lock/[resource]/[id]` topic. + +This means that if the lock is acquired or released by another user while the current user is on the page, the return value will be updated. + +```tsx +import { useGetLockLive } from '@react-admin/ra-core-ee'; + +const LockStatus = () => { + const { data: lock } = useGetLockLive(); + const { identity } = useGetIdentity(); + if (!lock) return No lock; + if (lock.identity === identity?.id) return Locked by you; + return Locked by {lock.identity}; +}; +``` + +`useGetLockLive` reads the current resource and record id from the `ResourceContext` and `RecordContext`. You can provide them explicitly if you are not in such a context: + +```tsx +const { data: lock } = useGetLockLive('posts', { id: 123 }); +``` diff --git a/docs_headless/src/content/docs/useGetLocks.md b/docs_headless/src/content/docs/useGetLocks.md new file mode 100644 index 00000000000..613731d9789 --- /dev/null +++ b/docs_headless/src/content/docs/useGetLocks.md @@ -0,0 +1,71 @@ +--- +title: "useGetLocks" +--- + +A hook that gets all the locks for a given resource. Calls `dataProvider.getLocks()` on mount. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +Here is how to use it in a custom list, to disable edit and delete buttons for locked records: + +```tsx +import { WithListContext, useRecordContext } from 'ra-core'; +import { useGetLocks, type Lock } from '@react-admin/ra-core-ee'; +import { DeleteButton } from '@components/ui/DeleteButton'; +import { LockableEditButton } from '@components/ui/DeleteButton'; + +const MyPostGrid = () => { + const resource = useResourceContext(); + const { data: locks } = useGetLocks(resource); + return ( +
    + isPending ? null : ( +
  • + + +
  • + )} + /> +
+ ); +}; + +const MyPostTitle = ({ locks }: { locks: Lock[] }) => { + const record = useRecordContext(); + const lock = locks.find(l => l.recordId === record.id); + + return ( +
+ {record.title}} />} /> + {lock && ( + + {` (Locked by ${lock.identity})`} + + )} +
+ ); +}; + +const MyPostActions = ({ locks }: { locks: Lock[] }) => { + const record = useRecordContext(); + const locked = locks.find(l => l.recordId === record.id); + + return ( +
+ + +
+ ); +}; +``` \ No newline at end of file diff --git a/docs_headless/src/content/docs/useGetLocksLive.md b/docs_headless/src/content/docs/useGetLocksLive.md new file mode 100644 index 00000000000..4918fb989dc --- /dev/null +++ b/docs_headless/src/content/docs/useGetLocksLive.md @@ -0,0 +1,41 @@ +--- +title: "useGetLocksLive" +--- + +Use the `useGetLocksLive` hook to get all the locks for a resource in real time. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +This hook calls `dataProvider.getLocks()` for the current resource on mount, and subscribes to live updates on the `lock/[resource]` topic. +This means that if a lock is acquired or released by another user while the current user is on the page, the return value will be updated. + +```tsx +import { useRecordContext } from 'ra-core'; +import { useGetLocksLive } from '@react-admin/ra-core-ee'; +import { Lock } from 'lucide-react'; + +export const LockField = () => { + const record = useRecordContext(); + const locks = useGetLocksLive(); + if (!record) return null; + const lock = locks?.find(lock => lock.recordId === record?.id); + if (!lock) return ; + return ; +}; +``` + +`useGetLocksLive` reads the current resource from the `ResourceContext`. You can provide it explicitly if you are not in such a context: + +```tsx +const { data: locks } = useGetLocksLive('posts'); +``` \ No newline at end of file diff --git a/docs_headless/src/content/docs/useGetOneLive.md b/docs_headless/src/content/docs/useGetOneLive.md new file mode 100644 index 00000000000..170ed6c0ae2 --- /dev/null +++ b/docs_headless/src/content/docs/useGetOneLive.md @@ -0,0 +1,40 @@ +--- +title: "useGetOneLive" +--- + +An alternative to `useGetOne()` that subscribes to live updates on the record + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +```tsx +import { useRecordContext } from 'ra-core'; +import { useGetOneLive } from '@react-admin/ra-core-ee'; + +const UserProfile = () => { + const record = useRecordContext(); + const { data, isLoading, error } = useGetOneLive('users', { + id: record.id, + }); + if (isLoading) { + return
Loading...
; + } + if (error) { + return

ERROR

; + } + return
User {data.username}
; +}; +``` + +The hook will subscribe to live updates on the record (topic: `resource/[resource]/[id]`) and will refetch the record when it is updated or deleted. + +See the [useGetOne](./useGetOne.md) documentation for the full list of parameters and return type. \ No newline at end of file diff --git a/docs_headless/src/content/docs/useLock.md b/docs_headless/src/content/docs/useLock.md new file mode 100644 index 00000000000..ba621d0b1a8 --- /dev/null +++ b/docs_headless/src/content/docs/useLock.md @@ -0,0 +1,35 @@ +--- +title: "useLock" +--- + +`useLock` is a low-level hook that returns a callback to call `dataProvider.lock()`, leveraging react-query's `useMutation`. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +```tsx +const [lock, { isLoading, error }] = useLock( + resource, + { id, identity, meta }, + options +); +``` + +The payload is an object with the following properties: + +- `id`: the record id (e.g. `123`) +- `identity`: an identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This usually comes from `authProvider.getIdentity()`. +- `meta`: an object that will be forwarded to the dataProvider (optional) + +The optional `options` argument is passed to react-query's `useMutation` hook. + +For most use cases, you won't need to call the `useLock` hook directly. Instead, you should use the `useLockOnMount` or `useLockOnCall` orchestration hooks, which are responsible for calling `useLock` and `useUnlock`. \ No newline at end of file diff --git a/docs_headless/src/content/docs/useLockCallbacks.md b/docs_headless/src/content/docs/useLockCallbacks.md new file mode 100644 index 00000000000..bb1ed3debd7 --- /dev/null +++ b/docs_headless/src/content/docs/useLockCallbacks.md @@ -0,0 +1,139 @@ +--- +title: "useLockCallbacks" +--- +This utility hook allows to easily get the callbacks to **lock** and **unlock** a record, as well as the current **lock status**. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +Use this hook e.g. to build a lock button: + +```tsx +import { useLockCallbacks } from '@react-admin/ra-core-ee'; +import { LoaderCircle, Lock } from 'lucide-react'; + +export const LockButton = () => { + const { + lock, + isLocked, + isLockedByCurrentUser, + isPending, + isLocking, + isUnlocking, + doLock, + doUnlock, + } = useLockCallbacks(); + + if (isPending) { + return null; + } + + return isLocked ? ( + isLockedByCurrentUser ? ( + + ) : ( + + ) + ) : ( + + ); +}; +``` + +You can also leverage this hook as a quick way to access the lock status of the current record: + +```tsx +import { useLockCallbacks } from '@react-admin/ra-core-ee'; + +export const MyToolbar = () => { + const { isLockedByCurrentUser } = useLockCallbacks(); + + return ( +
+ +
+ ); +}; +``` + +## Parameters + +`useLockCallbacks` accepts a single options parameter, with the following properties: + +| Name | Required | Type | Default Value | Description | +| ----------------------- | -------- | ------------ | --------------------------------- | --------------------------------------------------------------------------------------------- | +| `identity` | No | `Identifier` | From `AuthProvider.getIdentity()` | An identifier for the user who owns the lock. | +| `resource` | No | `string` | From `ResourceContext` | The resource name (e.g. `'posts'`). | +| `id` | No | `Identifier` | From `RecordContext` | The record id (e.g. `123`). | +| `meta` | No | `object` | - | Additional metadata forwarded to the dataProvider `lock()`, `unlock()` and `getLock()` calls. | +| `lockMutationOptions` | No | `object` | - | `react-query` mutation options, used to customize the lock side-effects. | +| `unlockMutationOptions` | No | `object` | - | `react-query` mutation options, used to customize the unlock side-effects. | +| `queryOptions` | No | `object` | - | `react-query` query options, used to customize the lock query side-effects. | + +You can call `useLockCallbacks` with no parameter, and it will guess the resource and record id from the context (or the route): + +```tsx +const { isLocked, error, isLocking } = useLockCallbacks(); +``` + +Or you can provide them explicitly: + +```tsx +const { isLocked, error, isLocking } = useLockCallbacks({ + resource: 'venues', + id: 123, + identity: 'John Doe', +}); +``` + +## Return value + +`useLockCallbacks` returns an object with the following properties: + +| Name | Type | Description | +| ----------------------- | ---------- | ------------------------------------------------------------------------- | +| `isLocked` | `boolean` | Whether the record is currently locked (possibly by another user) or not. | +| `isLockedByCurrentUser` | `boolean` | Whether the record is locked by the current user or not. | +| `lock` | `object` | The lock data. | +| `error` | `object` | The error object if any of the mutations or the query fails. | +| `isPending` | `boolean` | Whether the lock query is in progress. | +| `isLocking` | `boolean` | Whether the lock mutation is in progress. | +| `isUnlocking` | `boolean` | Whether the unlock mutation is in progress. | +| `doLock` | `function` | A callback to manually lock the record. | +| `doUnlock` | `function` | A callback to manually unlock the record. | +| `doLockAsync` | `function` | A callback to manually lock the record asynchronously. | +| `doUnlockAsync` | `function` | A callback to manually unlock the record asynchronously. | +| `lockQuery` | `object` | The `react-query` query object for the lock status. | +| `lockMutation` | `object` | The `react-query` mutation object for the lock mutation. | +| `unlockMutation` | `object` | The `react-query` mutation object for the unlock mutation. | \ No newline at end of file diff --git a/docs_headless/src/content/docs/useLockOnCall.md b/docs_headless/src/content/docs/useLockOnCall.md new file mode 100644 index 00000000000..aac0e1f7b9f --- /dev/null +++ b/docs_headless/src/content/docs/useLockOnCall.md @@ -0,0 +1,87 @@ +--- +title: "useLockOnCall" +--- + +A hook that gets a callback to lock a record and its mutation state. +`useLockOnCall` calls `dataProvider.lock()` when the callback is called. It relies on `authProvider.getIdentity()` to get the identity of the current user. It guesses the current `resource` and `recordId` from the context (or the route) if not provided. It releases the lock when the component unmounts by calling `dataProvider.unlock()`. + + + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +Use this hook in a toolbar, to let the user lock the record manually. + +```tsx +import { EditBase } from 'ra-core'; +import { useLockOnMount } from '@react-admin/ra-core-ee'; +import { Alert, AlertTitle, Box, Button } from '@material-ui/core'; + +const LockStatus = () => { + const [doLock, { data, error, isLoading }] = useLockOnCall(); + return ( +
+ {isLoading ? ( +
Locking post...
+ ) : error ? ( +
+
Failed to lock
+
Someone else is probably already locking it.
+
+ ) : data ? ( +
+
Post locked
+
Only you can edit it.
+
+ ) : ( + + )} +
+ ); +}; + +const PostEdit = () => ( + + + {/* The edit form*/} + +); +``` + +**Note**: If users close their tab/browser when on a page with a locked record, `useLockOnCall` will block the navigation and show a notification until the record is unlocked. Hence it's a good practice to give them a way to unlock the record manually, e.g. by using the `doUnlock` callback returned by the [`useLockCallbacks`](./useLockCallbacks.md) hook or the [``](./LockStatusBase.md) component. + +## Parameters + +`useLockOnCall` accepts a single options parameter, with the following properties (all optional): + +- `identity`: An identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This could be an authentication token for instance. Falls back to the identifier of the identity returned by the `AuthProvider.getIdentity()` function. +- `resource`: The resource name (e.g. `'posts'`). The hook uses the `ResourceContext` if not provided. +- `id`: The record id (e.g. `123`). The hook uses the `RecordContext` if not provided. +- `meta`: An object that will be forwarded to the `dataProvider.lock()` call +- `lockMutationOptions`: `react-query` mutation options, used to customize the lock side-effects for instance +- `unlockMutationOptions`: `react-query` mutation options, used to customize the unlock side-effects for instance + +```tsx +const LockButton = ({ resource, id, identity }) => { + const [doLock, lockMutation] = useLockOnCall({ resource, id, identity }); + return ( + + ); +}; +``` \ No newline at end of file diff --git a/docs_headless/src/content/docs/useLockOnMount.md b/docs_headless/src/content/docs/useLockOnMount.md new file mode 100644 index 00000000000..d65a0a9b2e4 --- /dev/null +++ b/docs_headless/src/content/docs/useLockOnMount.md @@ -0,0 +1,115 @@ +--- +title: "useLockOnMount" +--- + +This hook locks the current record on mount. + +`useLockOnMount` calls `dataProvider.lock()` on mount and `dataProvider.unlock()` on unmount to lock and unlock the record. It relies on `authProvider.getIdentity()` to get the identity of the current user. It guesses the current `resource` and `recordId` from the context (or the route) if not provided. + + + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +Use this hook e.g. in an `` component to lock the record so that it only accepts updates from the current user. + +```tsx +import { EditBase, Form } from 'ra-core'; +import { useLockOnMount } from '@react-admin/ra-core-ee'; + +const LockStatus = () => { + const { isLocked, error, isLoading } = useLockOnMount(); + return ( +
+ {isLoading &&

Locking post...

} + {error && ( +

+

Failed to lock
+
Someone else is probably already locking it.
+

+ )} + {isLocked && ( +

+

Post locked
+
Only you can edit it.
+

+ )} +
+ ); +}; + +const PostEdit = () => ( + + + {/* The edit form*/} + +); +``` + +**Note**: If users close their tab/browser when on a page with a locked record, `useLockOnMount` will block the navigation and show a notification until the record is unlocked. Hence it's a good practice to give them a way to unlock the record manually, e.g. by using the `doUnlock` callback returned by the hook or the [``](./LockStatusBase.md) component. + +## Parameters + +`useLockOnMount` accepts a single options parameter, with the following properties (all optional): + +- `identity`: An identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This could be an authentication token for instance. Falls back to the identifier of the identity returned by the `AuthProvider.getIdentity()` function. +- `resource`: The resource name (e.g. `'posts'`). The hook uses the `ResourceContext` if not provided. +- `id`: The record id (e.g. `123`). The hook uses the `RecordContext` if not provided. +- `meta`: An object that will be forwarded to the `dataProvider.lock()` call +- `lockMutationOptions`: `react-query` mutation options, used to customize the lock side-effects for instance +- `unlockMutationOptions`: `react-query` mutation options, used to customize the unlock side-effects for instance + +You can call `useLockOnMount` with no parameter, and it will guess the resource and record id from the context (or the route): + +```tsx +const { isLocked, error, isLoading } = useLockOnMount(); +``` + +Or you can provide them explicitly: + +```tsx +const { isLocked, error, isLoading } = useLockOnMount({ + resource: 'venues', + id: 123, + identity: 'John Doe', +}); +``` + +**Tip**: If the record can't be locked because another user is already locking it, you can use [`react-query`'s retry feature](https://react-query-v3.tanstack.com/guides/mutations#retry) to try again later: + +```tsx +const { isLocked, error, isLoading } = useLockOnMount({ + lockMutationOptions: { + // retry every 5 seconds, until the lock is acquired + retry: true, + retryDelay: 5000, + }, +}); +``` + +## Return value + +`useLockOnMount` returns an object with the following properties: + +- `isLocked`: Whether the record is successfully locked by this hook or not. +- `isLockedByCurrentUser`: Whether the record is locked by the current user or not. +- `lock`: The lock data. +- `error`: The error object if the lock attempt failed. +- `isLocking`: Whether the lock mutation is in progress. +- `isUnlocking`: Whether the unlock mutation is in progress. +- `doLock`: A callback to manually lock the record. +- `doUnlock`: A callback to manually unlock the record. +- `doLockAsync`: A callback to manually lock the record asynchronously. +- `doUnlockAsync`: A callback to manually unlock the record asynchronously. diff --git a/docs_headless/src/content/docs/usePublish.md b/docs_headless/src/content/docs/usePublish.md new file mode 100644 index 00000000000..530dadc71a5 --- /dev/null +++ b/docs_headless/src/content/docs/usePublish.md @@ -0,0 +1,112 @@ +--- +title: "usePublish" +--- + +Get a callback to publish an event on a topic. The callback returns a promise that resolves when the event is published. + +`usePublish` calls `dataProvider.publish()` to publish the event. It leverages react-query's `useMutation` hook to provide a callback. + +**Note**: Events should generally be published by the server, in reaction to an action by an end user. They should seldom be published directly by the client. This hook is provided mostly for testing purposes, but you may use it in your own custom components if you know what you're doing. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +`usePublish` returns a callback with the following signature: + +```tsx +const publish = usePublish(); +publish(topic, event, options); +``` + +For instance, in a chat application, when a user is typing a message, the following component publishes a `typing` event to the `chat/[channel]` topic: + +```tsx +import { useInput, useGetIdentity } from 'ra-core'; +import { usePublish } from '@react-admin/ra-core-ee'; + +const MessageInput = ({ channel }) => { + const [publish, { isLoading }] = usePublish(); + const { id, field, fieldState } = useInput({ source: 'message' }); + const { identity } = useGetIdentity(); + + const handleUserInput = event => { + publish(`chat/${channel}`, { + type: 'typing', + payload: { user: identity }, + }); + }; + + return ( + + ); +}; +``` + +The event format is up to you. It should at least contain a `type` property and may contain a `payload` property. The `payload` property can contain any data you want to send to the subscribers. + +Some hooks and components in this package are specialized to handle "CRUD" events, which are events with a `type` property set to `created`, `updated` or `deleted`. For instance: + +```js +{ + topic: `resource/${resource}/id`, + event: { + type: 'deleted', + payload: { ids: [id]}, + }, +} +``` + +See the [CRUD events](https://react-admin-ee.marmelab.com/documentation/ra-realtime#crud-events) section for more details. + +## Return Value + +`usePublish` returns an array with the following values: + +- `publish`: The callback to publish an event to a topic. +- `state`: The state of the mutation ([see react-query documentation](https://react-query-v3.tanstack.com/reference/useMutation)). Notable properties: + - `isLoading`: Whether the mutation is loading. + - `error`: The error if the mutation failed. + - `data`: The published event if the mutation succeeded. + +```tsx +const [publish, { isLoading, error, data }] = usePublish(); +``` + +## Callback Parameters + +The `publish` callback accepts the following parameters: + +- `topic`: The topic to publish the event on. +- `event`: The event to publish. It must contain a `type` property. +- `options`: `useMutation` options ([see react-query documentation](https://react-query-v3.tanstack.com/reference/useMutation)). Notable properties: + - `onSuccess`: A callback to call when the event is published. It receives the published event as its first argument. + - `onError`: A callback to call when the event could not be published. It receives the error as its first argument. + - `retry`: Whether to retry on failure. Defaults to `0`. + +```tsx +const [publish] = usePublish(); +publish( + 'chat/general', + { + type: 'message', + payload: { user: 'John', message: 'Hello!' }, + }, + { + onSuccess: event => console.log('Event published', event), + onError: error => console.log('Could not publish event', error), + retry: 3, + } +); +``` \ No newline at end of file diff --git a/docs_headless/src/content/docs/useSubscribe.md b/docs_headless/src/content/docs/useSubscribe.md new file mode 100644 index 00000000000..e2ef052af03 --- /dev/null +++ b/docs_headless/src/content/docs/useSubscribe.md @@ -0,0 +1,163 @@ +--- +title: "useSubscribe" +--- + +Subscribe to the events from a topic on mount (and unsubscribe on unmount). + + + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +The following component subscribes to the `messages/{channelName}` topic and displays a badge with the number of unread messages: + +```tsx +import { useState, useCallback } from 'react'; +import { useSubscribe } from '@react-admin/ra-core-ee'; + +const ChannelName = ({ name }) => { + const [nbMessages, setNbMessages] = useState(0); + + const callback = useCallback( + event => { + if (event.type === 'created') { + setNbMessages(count => count + 1); + } + }, + [setNbMessages] + ); + + useSubscribe(`messages/${name}`, callback); + + return nbMessages > 0 ? ( +

#{name} ({nbMessages} new messages)

+ ) : ( +

#{name}

+ ); +}; +``` + +## Parameters + +| Prop | Required | Type | Default | Description | +| ---------- | -------- | ---------- | ------- | ------------------------------------------------------------------ | +| `topic` | Optional | `string` | - | The topic to subscribe to. When empty, no subscription is created. | +| `callback` | Optional | `function` | - | The callback to execute when an event is received. | +| `options` | Optional | `object` | - | Options to modify the subscription / unsubscription behavior. | + +## `callback` + +This function will be called with the event as its first argument, so you can use it to update the UI. + +```tsx +useSubscribe(`messages/${name}`, event => { + if (event.type === 'created') { + setNbMessages(count => count + 1); + } +}); +``` + +**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions. + +```tsx +const callback = useCallback( + event => { + if (event.type === 'created') { + setNbMessages(count => count + 1); + } + }, + [setNbMessages] +); +useSubscribe(`messages/${name}`, callback); +``` + +The callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event. + +```tsx +import { useState, useCallback } from 'react'; +import { useSubscribe } from '@react-admin/ra-core-ee'; + +const JobProgress = ({ jobId }) => { + const [progress, setProgress] = useState(0); + const callback = useCallback( + (event, unsubscribe) => { + if (event.type === 'progress') { + setProgress(event.payload.progress); + } + if (event.type === 'completed') { + unsubscribe(); + } + }, + [setColor] + ); + useSubscribe(`jobs/${jobId}`, callback); + return ( +
{progress}%
+ ); +}; +``` + + + +## `options` + +The `options` object can contain the following properties: + +- `enabled`: Whether to subscribe or not. Defaults to `true` +- `once`: Whether to unsubscribe after the first event. Defaults to `false`. +- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`. + +You can use the `once` option to subscribe to a topic only once, and then unsubscribe. + +For instance, the following component subscribes to the `office/restart` topic and changes the message when the office is open, then unsubscribes from the topic: + +```tsx +import { useState } from 'react'; +import { useSubscribe } from '@react-admin/ra-core-ee'; + +const OfficeClosed = () => { + const [state, setState] = useState('closed'); + + useSubscribe('office/restart', () => setState('open'), { once: true }); + + return ( +
+ {state === 'closed' + ? 'Sorry, the office is closed for maintenance.' + : 'Welcome! The office is open.'} +
+ ); +}; +``` + + + +## `topic` + +The first argument of `useSubscribe` is the topic to subscribe to. It can be an arbitrary string. + +```tsx +useSubscribe('messages', event => { + // ... +}); +``` + +If you want to subscribe to CRUD events, instead of writing the topic manually like `resource/[resource]`, you can use the `useSubscribeToRecord` or `useSubscribeToRecordList` hooks. \ No newline at end of file diff --git a/docs_headless/src/content/docs/useSubscribeCallback.md b/docs_headless/src/content/docs/useSubscribeCallback.md new file mode 100644 index 00000000000..fdfd4679d8d --- /dev/null +++ b/docs_headless/src/content/docs/useSubscribeCallback.md @@ -0,0 +1,193 @@ +--- +title: "useSubscribeCallback" +--- + +Get a callback to subscribe to events on a topic and optionally unsubscribe on unmount. + +This is useful to start a subscription from an event handler, like a button click. + + + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +The following component subscribes to the `backgroundJobs/recompute` topic on click, and displays the progress of the background job: + +```tsx +import { useState, useCallback } from 'react'; +import { useDataProvider } from 'ra-core'; +import { useSubscribeCallback } from '@react-admin/ra-core-ee'; + +const LaunchBackgroundJob = () => { + const dataProvider = useDataProvider(); + const [progress, setProgress] = useState(0); + const callback = useCallback( + (event, unsubscribe) => { + setProgress(event.payload?.progress || 0); + if (event.payload?.progress === 100) { + unsubscribe(); + } + }, + [setProgress] + ); + const subscribe = useSubscribeCallback( + 'backgroundJobs/recompute', + callback + ); + + return ( +
+ +
+ ); +}; +``` + +## Parameters + +| Prop | Required | Type | Default | Description | +| ---------- | -------- | ---------- | ------- | ------------------------------------------------------------------ | +| `topic` | Optional | `string` | - | The topic to subscribe to. When empty, no subscription is created. | +| `callback` | Optional | `function` | - | The callback to execute when an event is received. | +| `options` | Optional | `object` | - | Options to modify the subscription / unsubscription behavior. | + +## `callback` + +Whenever an event is published on the `topic` passed as the first argument, the function passed as the second argument will be called with the event as a parameter. + +```tsx +const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => { + if (event.type === 'progress') { + setProgress(event.payload.progress); + } +}); + +// later +subscribe(); +``` + +**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions. + +```tsx +const callback = useCallback( + event => { + if (event.type === 'progress') { + setProgress(event.payload.progress); + } + }, + [setProgress] +); +``` + +The callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event. + +```tsx +const subscribe = useSubscribeCallback( + 'backgroundJobs/recompute', + (event, unsubscribe) => { + if (event.type === 'completed') { + setProgress(100); + unsubscribe(); + } + } +); +``` + +## `options` + +The `options` object can contain the following properties: + +- `enabled`: Whether to subscribe or not. Defaults to `true` +- `once`: Whether to unsubscribe after the first event. Defaults to `false`. +- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`. + +You can use the `once` option to subscribe to a topic only once, and then unsubscribe. + +For instance, the following component subscribes to the `backgroundJobs/recompute` topic on click, displays a notification when the background job is complete, then unsubscribes: + +```jsx +import { useDataProvider, useNotify } from 'ra-core'; +import { useSubscribeCallback } from '@react-admin/ra-core-ee'; + +const LaunchBackgroundJob = () => { + const dataProvider = useDataProvider(); + const notify = useNotify(); + + const subscribe = useSubscribeCallback( + 'backgroundJobs/recompute', + event => + notify('Recompute complete: %{summary}', { + type: 'success', + messageArgs: { + summary: event.payload?.summary, + }, + }), + { + unsubscribeOnUnmount: false, // show the notification even if the user navigates away + once: true, // unsubscribe after the first event + } + ); + + return ( + + ); +}; +``` + + + +You can use the `unsubscribeOnUnmount` option to keep the subscription alive after the component unmounts. + +This can be useful when you want the subscription to persist across multiple pages. + +```tsx +const subscribe = useSubscribeCallback( + 'backgroundJobs/recompute', + event => setProgress(event.payload?.progress || 0), + { + unsubscribeOnUnmount: false, // don't unsubscribe on unmount + } +); +``` + +## `topic` + +The first argument of `useSubscribeCallback` is the topic to subscribe to. It can be an arbitrary string. + +```tsx +const subscribe = useSubscribeCallback('backgroundJobs/recompute', event => { + // ... +}); + +// later +subscribe(); +``` \ No newline at end of file diff --git a/docs_headless/src/content/docs/useSubscribeToRecord.md b/docs_headless/src/content/docs/useSubscribeToRecord.md new file mode 100644 index 00000000000..70b1974363f --- /dev/null +++ b/docs_headless/src/content/docs/useSubscribeToRecord.md @@ -0,0 +1,223 @@ +--- +title: "useSubscribeToRecord" +--- + +This specialized version of `useSubscribe` subscribes to events concerning a single record. + + + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +The hook expects a callback function as its only argument, as it guesses the record and resource from the current context. The callback will be executed whenever an event is published on the `resource/[resource]/[recordId]` topic. + +For instance, the following component displays a message when the record is updated by someone else: + +```tsx +const WarnWhenUpdatedBySomeoneElse = () => { + const [open, setOpen] = useState(false); + const [author, setAuthor] = useState(null); + const handleClose = () => { + setOpen(false); + }; + const { refetch } = useEditContext(); + const refresh = () => { + refetch(); + handleClose(); + }; + const { + formState: { isDirty }, + } = useFormContext(); + + useSubscribeToRecord((event: Event) => { + if (event.type === 'edited') { + if (isDirty) { + setOpen(true); + setAuthor(event.payload.user); + } else { + refetch(); + } + } + }); + return open ? ( +
+

+ Post Updated by {author} +

+

+ Your changes and their changes may conflict. What do you + want to do? +

+
+ + +
+
+ ) : null; +}; + +const PostEdit = () => ( + +
+ {/* Inputs... */} + + +
+); +``` + +`useSubscribeToRecord` reads the current resource and record from the `ResourceContext` and `RecordContext` respectively. In the example above, the notification is displayed when the app receives an event on the `resource/books/123` topic. + +Just like `useSubscribe`, `useSubscribeToRecord` unsubscribes from the topic when the component unmounts. + +**Tip**: In the example above, `` creates the `RecordContext`- that's why the `useSubscribeToRecord` hook is used in its child component instead of in the `` component. + +You can provide the resource and record id explicitly if you are not in such contexts: + +```tsx +useSubscribeToRecord( + event => { + /* ... */ + }, + 'posts', + 123 +); +``` + +**Tip**: If your reason to subscribe to events on a record is to keep the record up to date, you should use [the `useGetOneLive` hook](./useGetOneLive.md) instead. + +## Parameters + +| Prop | Required | Type | Default | Description | +| ---------- | -------- | ---------- | ------- | --------------------------------------------------------------------------------------- | +| `callback` | Required | `function` | - | The callback to execute when an event is received. | +| `resource` | Optional | `string` | - | The resource to subscribe to. Defaults to the resource in the `ResourceContext`. | +| `recordId` | Optional | `string` | - | The record id to subscribe to. Defaults to the id of the record in the `RecordContext`. | +| `options` | Optional | `object` | - | The subscription options. | + +## `callback` + +Whenever an event is published on the `resource/[resource]/[recordId]` topic, the function passed as the first argument will be called with the event as a parameter. + +```tsx +const [open, setOpen] = useState(false); +const [author, setAuthor] = useState(null); +const { refetch } = useEditContext(); +const { + formState: { isDirty }, +} = useFormContext(); +useSubscribeToRecord((event: Event) => { + if (event.type === 'edited') { + if (isDirty) { + setOpen(true); + setAuthor(event.payload.user); + } else { + refetch(); + } + } +}); +``` + +**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions. + +```tsx +const [open, setOpen] = useState(false); +const [author, setAuthor] = useState(null); +const { refetch } = useEditContext(); +const { + formState: { isDirty }, +} = useFormContext(); + +const handleEvent = useCallback( + (event: Event) => { + if (event.type === 'edited') { + if (isDirty) { + setOpen(true); + setAuthor(event.payload.user); + } else { + refetch(); + } + } + }, + [isDirty, refetch, setOpen, setAuthor] +); + +useSubscribeToRecord(handleEvent); +``` + +Just like for `useSubscribe`, the callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event. + +```tsx +useSubscribeToRecord((event: Event, unsubscribe) => { + if (event.type === 'deleted') { + // do something + unsubscribe(); + } + if (event.type === 'edited') { + if (isDirty) { + setOpen(true); + setAuthor(event.payload.user); + } else { + refetch(); + } + } +}); +``` + +## `options` + +The `options` object can contain the following properties: + +- `enabled`: Whether to subscribe or not. Defaults to `true` +- `once`: Whether to unsubscribe after the first event. Defaults to `false`. +- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`. + +See [`useSubscribe`](./useSubscribe.md) for more details. + +## `recordId` + +The record id to subscribe to. By default, `useSubscribeToRecord` builds the topic it subscribes to using the id of the record in the `RecordContext`. But you can override this behavior by passing a record id as the third argument. + +```tsx +// will subscribe to the 'resource/posts/123' topic +useSubscribeToRecord( + event => { + /* ... */ + }, + 'posts', + 123 +); +``` + +Note that if you pass a null record id, the hook will not subscribe to any topic. + +## `resource` + +The resource to subscribe to. By default, `useSubscribeToRecord` builds the topic it subscribes to using the resource in the `ResourceContext`. But you can override this behavior by passing a resource name as the second argument. + +```tsx +// will subscribe to the 'resource/posts/123' topic +useSubscribeToRecord( + event => { + /* ... */ + }, + 'posts', + 123 +); +``` + +Note that if you pass an empty string as the resource name, the hook will not subscribe to any topic. \ No newline at end of file diff --git a/docs_headless/src/content/docs/useSubscribeToRecordList.md b/docs_headless/src/content/docs/useSubscribeToRecordList.md new file mode 100644 index 00000000000..9337325dba7 --- /dev/null +++ b/docs_headless/src/content/docs/useSubscribeToRecordList.md @@ -0,0 +1,167 @@ +--- +title: "useSubscribeToRecordList" +--- + +This specialized version of `useSubscribe` subscribes to events concerning a list of records. + + + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +`useSubscribeToRecordList` expects a callback function as its first argument. It will be executed whenever an event is published on the `resource/[resource]` topic. + +For instance, the following component displays notifications when a record is created, updated, or deleted by someone else: + +```tsx +import React from 'react'; +import { useNotify, useListContext } from 'ra-core'; +import { useSubscribeToRecordList } from '@react-admin/ra-core-ee'; + +const ListWatcher = () => { + const notify = useNotify(); + const { refetch, data } = useListContext(); + useSubscribeToRecordList(event => { + switch (event.type) { + case 'created': { + notify('New movie created'); + refetch(); + break; + } + case 'updated': { + if (data.find(record => record.id === event.payload.ids[0])) { + notify(`Movie #${event.payload.ids[0]} updated`); + refetch(); + } + break; + } + case 'deleted': { + if (data.find(record => record.id === event.payload.ids[0])) { + notify(`Movie #${event.payload.ids[0]} deleted`); + refetch(); + } + break; + } + } + }); + return null; +}; + +const MovieList = () => ( + + {/* The list view*/} + + +); +``` + +## Parameters + +| Prop | Required | Type | Default | Description | +| ---------- | -------- | ---------- | ------- | -------------------------------------------------------------------------------- | +| `callback` | Required | `function` | - | The callback function to execute when an event is published on the topic. | +| `resource` | Optional | `string` | - | The resource to subscribe to. Defaults to the resource in the `ResourceContext`. | +| `options` | Optional | `object` | - | The subscription options. | + +## `callback` + +Whenever an event is published on the `resource/[resource]` topic, the function passed as the first argument will be called with the event as a parameter. + +```tsx +const notify = useNotify(); +const { refetch, data } = useListContext(); +useSubscribeToRecordList(event => { + switch (event.type) { + case 'created': { + notify('New movie created'); + refetch(); + break; + } + case 'updated': { + if (data.find(record => record.id === event.payload.ids[0])) { + notify(`Movie #${event.payload.ids[0]} updated`); + refetch(); + } + break; + } + case 'deleted': { + if (data.find(record => record.id === event.payload.ids[0])) { + notify(`Movie #${event.payload.ids[0]} deleted`); + refetch(); + } + break; + } + } +}); +``` + +**Tip**: Memoize the callback using `useCallback` to avoid unnecessary subscriptions/unsubscriptions. + +```tsx +const notify = useNotify(); +const { refetch, data } = useListContext(); +const callback = useCallback( + event => { + switch (event.type) { + case 'created': { + notify('New movie created'); + refetch(); + break; + } + case 'updated': { + if (data.find(record => record.id === event.payload.ids[0])) { + notify(`Movie #${event.payload.ids[0]} updated`); + refetch(); + } + break; + } + case 'deleted': { + if (data.find(record => record.id === event.payload.ids[0])) { + notify(`Movie #${event.payload.ids[0]} deleted`); + refetch(); + } + break; + } + } + }, + [data, refetch, notify] +); +useSubscribeToRecordList(callback); +``` + +Just like for `useSubscribe`, the callback function receives an `unsubscribe` callback as its second argument. You can call it to unsubscribe from the topic after receiving a specific event. + +## `options` + +The `options` object can contain the following properties: + +- `enabled`: Whether to subscribe or not. Defaults to `true` +- `once`: Whether to unsubscribe after the first event. Defaults to `false`. +- `unsubscribeOnUnmount`: Whether to unsubscribe on unmount. Defaults to `true`. + +See [`useSubscribe`](./useSubscribe.md) for more details. + +## `resource` + +`useSubscribeToRecordList` reads the current resource from the `ResourceContext`. You can provide the resource explicitly if you are not in such a context: + +```tsx +useSubscribeToRecordList(event => { + if (event.type === 'updated') { + notify('Post updated'); + refresh(); + } +}, 'posts'); +``` \ No newline at end of file diff --git a/docs_headless/src/content/docs/useUnlock.md b/docs_headless/src/content/docs/useUnlock.md new file mode 100644 index 00000000000..ec68350dd22 --- /dev/null +++ b/docs_headless/src/content/docs/useUnlock.md @@ -0,0 +1,33 @@ +--- +title: "useUnlock" +--- + +`useUnlock` is a low-level hook that returns a callback to call `dataProvider.unlock()`, leveraging react-query's `useMutation`. + +This feature requires a valid [Enterprise Edition](https://marmelab.com/ra-enterprise/) subscription. + +## Installation + +```bash +npm install --save @react-admin/ra-core-ee +# or +yarn add @react-admin/ra-core-ee +``` + +## Usage + +```tsx +const [unlock, { isLoading, error }] = useUnlock( + resource, + { id, identity, meta }, + options +); +``` + +The payload is an object with the following properties: + +- `id`: the record id (e.g. `123`) +- `identity`: an identifier (string or number) corresponding to the identity of the locker (e.g. `'julien'`). This usually comes from `authProvider.getIdentity()` +- `meta`: an object that will be forwarded to the dataProvider (optional) + +The optional `options` argument is passed to react-query's `useMutation` hook. \ No newline at end of file diff --git a/docs_headless/src/styles/global.css b/docs_headless/src/styles/global.css index 44a9b393722..0de57d232a3 100644 --- a/docs_headless/src/styles/global.css +++ b/docs_headless/src/styles/global.css @@ -13,7 +13,7 @@ --sl-color-accent-high: #ff78ac; } -img.icon { +img.icon { display: inline; box-shadow: none; margin: 0; @@ -44,7 +44,7 @@ img.icon { .expressive-code button.cb-fullscreen__button { color: var(--ec-frm-inlBtnFg); -} +} /* Auth and Data Provider icons */ @@ -96,3 +96,57 @@ img.icon { overflow-wrap: break-word; } +a.enterprise { + position: relative; + padding-right: 26px; +} + +a.enterprise .sl-badge { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +a.enterprise span:not(.sl-badge)::after { + content: ''; + background-repeat: no-repeat; + width: 16px; + height: 16px; + position: absolute; + top: 6px; + right: 6px; +} + +a.enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-dark.svg'); +} +a[aria-current='page'].enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-dark.svg'); +} + +[data-theme="light"] a.enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-light.svg'); +} +[data-theme="light"] a[aria-current='page'].enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-dark.svg'); +} + +[data-theme="dark"] a.enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-dark.svg'); +} +[data-theme="dark"] a[aria-current='page'].enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-light.svg'); +} + +@media (prefers-color-scheme: dark) { + a.enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-dark.svg'); + } + a[aria-current='page'].enterprise span:not(.sl-badge)::after { + background-image: url('/public/premium-light.svg'); + } +}