diff --git a/documentation/docs/api-references/components/refine-config.md b/documentation/docs/api-references/components/refine-config.md index 368b8f311f01..ee3c004a3047 100644 --- a/documentation/docs/api-references/components/refine-config.md +++ b/documentation/docs/api-references/components/refine-config.md @@ -249,6 +249,14 @@ Custom route name
+## `liveProvider` + +**refine** lets you add Realtime support to your app via `liveProvider`. It can be used to update and show data in Realtime throughout your app. + +[Refer to live provider documentation for detailed information. →](api-references/providers/live-provider.md) + +
+ ## `catchAll` When the app is navigated to a non-existent route, **refine** shows a default error page. A custom error component can be used for this error page by passing the customized component to `catchAll` property: @@ -349,6 +357,19 @@ Default value is `false`.
+## `liveMode` + +Whether to update data automatically (`auto`) or not (`manual`) if a related live event is received. The `off` value is used to avoid creating a subscription. + +[Refer to live provider documentation for detailed information. →](api-references/providers/live-provider.md#livemode) + + +## `onLiveEvent` + +Callback to handle all live events. + +[Refer to live provider documentation for detailed information. →](api-references/providers/live-provider.md#refine) + ## `configProviderProps` Ant Design's [ConfigProvider](https://ant.design/components/config-provider) which includes default configurations can be changed using `configProviderProps`. diff --git a/documentation/docs/api-references/hooks/data/useList.md b/documentation/docs/api-references/hooks/data/useList.md index fbb5f94e4115..743793426b3f 100644 --- a/documentation/docs/api-references/hooks/data/useList.md +++ b/documentation/docs/api-references/hooks/data/useList.md @@ -263,6 +263,9 @@ const postListQueryResult = useList({ | successNotification | Successful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | `false` | | errorNotification | Unsuccessful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Error (status code: `statusCode`)" | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Config parameters diff --git a/documentation/docs/api-references/hooks/data/useMany.md b/documentation/docs/api-references/hooks/data/useMany.md index 582d4380138b..3ae17880880d 100644 --- a/documentation/docs/api-references/hooks/data/useMany.md +++ b/documentation/docs/api-references/hooks/data/useMany.md @@ -98,6 +98,9 @@ After query runs, the `categoryQueryResult` will include the retrieved data: | successNotification | Successful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | `false` | | errorNotification | Unsuccessful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Error (status code: `statusCode`)" | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Type Parameters diff --git a/documentation/docs/api-references/hooks/data/useOne.md b/documentation/docs/api-references/hooks/data/useOne.md index 870df83c9842..91db4ff66a1f 100644 --- a/documentation/docs/api-references/hooks/data/useOne.md +++ b/documentation/docs/api-references/hooks/data/useOne.md @@ -82,14 +82,17 @@ After query runs, the `categoryQueryResult` will include the retrieved data: ### Properties -| Property | Description | Type | Default | -| --------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------- | -|
resource
Required
| Resource name for API data interactions | `string` | | -| id
Required
| id of the item in the resource | `string` | | -| queryOptions | `react-query`'s `useQuery` options | ` UseQueryOptions<`
`{ data: TData; },`
`TError>` | | -| successNotification | Successful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | `false` | -| errorNotification | Unsuccessful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Error (status code: `statusCode`)" | -| metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| Property | Description | Type | Default | +| --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | ----------------------------------- | +|
resource
Required
| Resource name for API data interactions | `string` | | +| id
Required
| id of the item in the resource | `string` | | +| queryOptions | `react-query`'s `useQuery` options | ` UseQueryOptions<`
`{ data: TData; },`
`TError>` | | +| successNotification | Successful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | `false` | +| errorNotification | Unsuccessful Query notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Error (status code: `statusCode`)" | +| metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Type Parameters diff --git a/documentation/docs/api-references/hooks/field/useCheckboxGroup.md b/documentation/docs/api-references/hooks/field/useCheckboxGroup.md index 174358b1d908..8209c72b1525 100644 --- a/documentation/docs/api-references/hooks/field/useCheckboxGroup.md +++ b/documentation/docs/api-references/hooks/field/useCheckboxGroup.md @@ -165,6 +165,9 @@ const { checkboxGroupProps } = useCheckboxGroup({ | sort | Allows us to sort the options | [`CrudSorting`](../../interfaces.md#crudsorting) | | | queryOptions | react-query [useQuery](https://react-query.tanstack.com/reference/useQuery) options | ` UseQueryOptions, TError>` | | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Return values diff --git a/documentation/docs/api-references/hooks/field/useRadioGroup.md b/documentation/docs/api-references/hooks/field/useRadioGroup.md index 41e31b6cb84d..0cd948564e81 100644 --- a/documentation/docs/api-references/hooks/field/useRadioGroup.md +++ b/documentation/docs/api-references/hooks/field/useRadioGroup.md @@ -165,6 +165,9 @@ const { radioGroupProps } = useRadioGroup({ | sort | Allows us to sort the options | [`CrudSorting`](../../interfaces.md#crudsorting) | | | queryOptions | react-query [useQuery](https://react-query.tanstack.com/reference/useQuery) options | ` UseQueryOptions, TError>` | | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Return values diff --git a/documentation/docs/api-references/hooks/field/useSelect.md b/documentation/docs/api-references/hooks/field/useSelect.md index c64ede01121d..ece157815d59 100644 --- a/documentation/docs/api-references/hooks/field/useSelect.md +++ b/documentation/docs/api-references/hooks/field/useSelect.md @@ -222,6 +222,9 @@ const { selectProps } = useSelect({ | queryOptions | react-query [useQuery](https://react-query.tanstack.com/reference/useQuery) options | ` UseQueryOptions, TError>` | | | defaultValueQueryOptions | react-query [useQuery](https://react-query.tanstack.com/reference/useQuery) options | ` UseQueryOptions, TError>` | | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Return values diff --git a/documentation/docs/api-references/hooks/form/useDrawerForm.md b/documentation/docs/api-references/hooks/form/useDrawerForm.md index 8d5e1634b1f9..baecc5d379f3 100644 --- a/documentation/docs/api-references/hooks/form/useDrawerForm.md +++ b/documentation/docs/api-references/hooks/form/useDrawerForm.md @@ -247,6 +247,9 @@ The `saveButtonProps` and `deleteButtonProps` gives us the ability of saving and | successNotification | Successful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Successfully created `resource`" or "Successfully updated `resource`" | | errorNotification | Unsuccessful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "There was an error creating `resource` (status code: `statusCode`)" or "Error when updating `resource` (status code: `statusCode`)" | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | > `*`: These props have default values in `RefineContext` and can also be set on **<[Refine](/api-references/components/refine-config.md)>** component. `useDrawerForm` will use what is passed to `` as default but a local value will override it. diff --git a/documentation/docs/api-references/hooks/form/useForm.md b/documentation/docs/api-references/hooks/form/useForm.md index b3e39f3d5663..d87f608d8532 100644 --- a/documentation/docs/api-references/hooks/form/useForm.md +++ b/documentation/docs/api-references/hooks/form/useForm.md @@ -145,6 +145,9 @@ const { clone } = useNavigation(); | successNotification | Successful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Successfully created `resource`" or "Successfully updated `resource`" | | errorNotification | Unsuccessful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "There was an error creating `resource` (status code: `statusCode`)" or "Error when updating `resource` (status code: `statusCode`)" | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | > `*`: These props have default values in `RefineContext` and can also be set on **<[Refine](/api-references/components/refine-config.md)>** component. `useForm` will use what is passed to `` as default but a local value will override it. diff --git a/documentation/docs/api-references/hooks/form/useModalForm.md b/documentation/docs/api-references/hooks/form/useModalForm.md index 37616b042b29..1b1f1f7a5873 100644 --- a/documentation/docs/api-references/hooks/form/useModalForm.md +++ b/documentation/docs/api-references/hooks/form/useModalForm.md @@ -225,6 +225,9 @@ Don't forget to pass the record id to `show` to fetch the record data. This is n | successNotification | Successful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Successfully created `resource`" or "Successfully updated `resource`" | | errorNotification | Unsuccessful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "There was an error creating `resource` (status code: `statusCode`)" or "Error when updating `resource` (status code: `statusCode`)" | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | > `*`: These props have default values in `RefineContext` and can also be set on **<[Refine](/api-references/components/refine-config.md)>** component. `useModalForm` will use what is passed to `` as default but a local value will override it. diff --git a/documentation/docs/api-references/hooks/form/useStepsForm.md b/documentation/docs/api-references/hooks/form/useStepsForm.md index c1c1ae44116c..409bd09e1ac5 100644 --- a/documentation/docs/api-references/hooks/form/useStepsForm.md +++ b/documentation/docs/api-references/hooks/form/useStepsForm.md @@ -454,6 +454,9 @@ export const PostCreate: React.FC = () => { | successNotification | Successful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "Successfully created `resource`" or "Successfully updated `resource`" | | errorNotification | Unsuccessful Mutation notification | [`SuccessErrorNotification`](../../interfaces.md#successerrornotification) | "There was an error creating `resource` (status code: `statusCode`)" or "Error when updating `resource` (status code: `statusCode`)" | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | > `*`: These props have default values in `RefineContext` and can also be set on **<[Refine](/api-references/components/refine-config.md)>** component. `useModalForm` will use what is passed to `` as default but a local value will override it. diff --git a/documentation/docs/api-references/hooks/live/usePublish.md b/documentation/docs/api-references/hooks/live/usePublish.md new file mode 100644 index 000000000000..4c3a9388a449 --- /dev/null +++ b/documentation/docs/api-references/hooks/live/usePublish.md @@ -0,0 +1,32 @@ +--- +id: usePublish +title: usePublish +--- + +If you need to publish a custom events **refine** provides the `usePublish` hook for it, It returns the `publish` method from [`liveProvider`](/api-references/providers/live-provider.md#publish) under the hood. + +## Usage + +:::caution +This hook can only be used if `liveProvider`'s `publish` method is provided. +::: + +```tsx +import { usePublish } from "@pankod/refine"; + +const publish = usePublish(); + +publish({ + channel: "custom-channel-name", + type: "custom-event-name", + payload: { + "custom-property": "custom-property-value", + }, + date: new Date(), +}); +``` + +:::info + +You can subscribe to event with [`useSubscription`](/api-references/hooks/live/useSubscription.md). +::: diff --git a/documentation/docs/api-references/hooks/live/useSubscription.md b/documentation/docs/api-references/hooks/live/useSubscription.md new file mode 100644 index 000000000000..ae0e78e2bb7c --- /dev/null +++ b/documentation/docs/api-references/hooks/live/useSubscription.md @@ -0,0 +1,27 @@ +--- +id: useSubscription +title: useSubscription +--- + +It is used to subscribe to a Realtime channel. It returns the `subscribe` method from [`liveProvider`](/api-references/providers/live-provider.md#subscribe) under the hood. + +## Usage + +:::caution +This hook can only be used if `liveProvider` is provided. +::: + +```tsx +import { useSubscription } from "@pankod/refine"; + +useSubscription({ + channel: "channel-name", + types: ["event-name", "another-event-name"] + onLiveEvent: (event) => {}, +}); +``` + +:::info + +You can publish events with [`usePublish`](/api-references/hooks/live/usePublish.md). +::: \ No newline at end of file diff --git a/documentation/docs/api-references/hooks/show/useShow.md b/documentation/docs/api-references/hooks/show/useShow.md index 7a1e077eb909..a3dab9890f36 100644 --- a/documentation/docs/api-references/hooks/show/useShow.md +++ b/documentation/docs/api-references/hooks/show/useShow.md @@ -211,11 +211,14 @@ To show data in the drawer, you can do it by simply replacing `` with ` void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Return values diff --git a/documentation/docs/api-references/hooks/show/useSimpleList.md b/documentation/docs/api-references/hooks/show/useSimpleList.md index c35610dde4ba..3e9753922af9 100644 --- a/documentation/docs/api-references/hooks/show/useSimpleList.md +++ b/documentation/docs/api-references/hooks/show/useSimpleList.md @@ -160,6 +160,9 @@ You can use `AntdList.Item` and `AntdList.Item.Meta` like `` component fro | onSearch | When the search form is submitted, it creates the 'CrudFilters' object. See here to create a [search form][list search] | `Function` | | | queryOptions | `react-query`'s `useQuery` options | ` UseQueryOptions<{ data: TData[] }, TError>` | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Type Parameters diff --git a/documentation/docs/api-references/hooks/table/useEditableTable.md b/documentation/docs/api-references/hooks/table/useEditableTable.md index 4a163a34e31d..b48758759b3d 100644 --- a/documentation/docs/api-references/hooks/table/useEditableTable.md +++ b/documentation/docs/api-references/hooks/table/useEditableTable.md @@ -310,17 +310,20 @@ export const PostList: React.FC = () => { ### Properties -| Key | Description | Type | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -| permanentFilter | Default and unchangeable filter. | [`CrudFilters`][crudfilters] | -| initialCurrent | Initial page index. | `number` | -| initialPageSize | Number of records shown per initial number of pages. | `number` | -| initialSorter | Initial sorting. | [`CrudSorting`][crudsorting] | -| initialFilter | Initial filtering. | [`CrudFilters`][crudfilters] | -| syncWithLocation | Sortings, filters, page index and records shown per page are tracked by browser history. | `boolean` | -| onSearch | When the search form is submitted, it creates the 'CrudFilters' object. Refer to [search form][table search] to learn how to create a search form. | `Function` | -| queryOptions | `react-query`'s `useQuery` options | ` UseQueryOptions<`
`{ data: TData[]; },`
`TError>` | -| metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | +| Key | Description | Type | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| permanentFilter | Default and unchangeable filter. | [`CrudFilters`][crudfilters] | +| initialCurrent | Initial page index. | `number` | +| initialPageSize | Number of records shown per initial number of pages. | `number` | +| initialSorter | Initial sorting. | [`CrudSorting`][crudsorting] | +| initialFilter | Initial filtering. | [`CrudFilters`][crudfilters] | +| syncWithLocation | Sortings, filters, page index and records shown per page are tracked by browser history. | `boolean` | +| onSearch | When the search form is submitted, it creates the 'CrudFilters' object. Refer to [search form][table search] to learn how to create a search form. | `Function` | +| queryOptions | `react-query`'s `useQuery` options | ` UseQueryOptions<`
`{ data: TData[]; },`
`TError>` | +| metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | ### Type Parameters diff --git a/documentation/docs/api-references/hooks/table/useTable.md b/documentation/docs/api-references/hooks/table/useTable.md index 607851a5ee20..9cfd5f370f61 100644 --- a/documentation/docs/api-references/hooks/table/useTable.md +++ b/documentation/docs/api-references/hooks/table/useTable.md @@ -357,6 +357,9 @@ Filters we give to `initialFilter` are default filters. In order to prevent filt | onSearch | When the search form is submitted, it creates the 'CrudFilters' object. Refer to [search form][table search] to learn how to create a search form | `Function` | | queryOptions | `react-query`'s `useQuery` options | ` UseQueryOptions<`
`{ data: TData[]; },`
`TError>` | | metaData | Metadata query for `dataProvider` | [`MetaDataQuery`](/api-references/interfaces.md#metadataquery) | {} | +| [liveMode](/api-references/providers/live-provider.md#usage-in-a-hook) | Whether to update data automatically (`"auto"`) or not (`"manual"`) if a related live event is received. The "off" value is used to avoid creating a subscription. | [``"auto"` \| `"manual"` \| `"off"``](/api-references/interfaces.md#livemodeprops) | `"off"` | +| liveParams | Params to pass to `liveProvider`'s `subscribe` method if `liveMode` is enabled. | [`{ ids?: string[]; [key: string]: any; }`](/api-references/interfaces.md#livemodeprops) | `undefined` | +| onLiveEvent | Callback to handle all related live events of this hook. | [`(event: LiveEvent) => void`](/api-references/interfaces.md#livemodeprops) | `undefined` | ### Type Parameters diff --git a/documentation/docs/api-references/interfaces.md b/documentation/docs/api-references/interfaces.md index ea17a76a6bc8..629c1aec31e2 100644 --- a/documentation/docs/api-references/interfaces.md +++ b/documentation/docs/api-references/interfaces.md @@ -181,3 +181,20 @@ ButtonProps | ------- | --------- | | can | `boolean` | | reason? | `string` | + +## LiveEvent + +| Key | Type | +| ------- | -------------------------------------------------------------- | +| channel | `string` | +| type | `"deleted"` \| `"updated"` \| `"created"` \| "`*`" \| `string` | +| payload | `{ids?: string[]; [x: string]: any; }` | +| date | `Date` | + +## LiveModeProps + +| Key | Type | +| ------------ | -------------------------------------- | +| liveMode? | `"auto"` \| `"manual"` \| `"off"` | +| liveParams? | `{ids?: string[]; [x: string]: any; }` | +| onLiveEvent? | `(event: LiveEvent) => void` | diff --git a/documentation/docs/api-references/providers/live-provider.md b/documentation/docs/api-references/providers/live-provider.md new file mode 100644 index 000000000000..c64e83eb8424 --- /dev/null +++ b/documentation/docs/api-references/providers/live-provider.md @@ -0,0 +1,570 @@ +--- +id: live-provider +title: Live Provider +--- + +## Overview + +**refine** lets you add Realtime support to your app via `liveProvider` prop for [``](api-references/components/refine-config.md). It can be used to update and show data in Realtime throughout your app. **refine** remains agnostic in its API to allow different solutions([Ably](https://ably.com), [Socket.IO](https://socket.io/), [Mercure](https://mercure.rocks/), [supabase](https://supabase.com), etc.) to be integrated. + +A live provider must include following methods: + +```ts +const liveProvider = { + subscribe: ({ channel, params: { ids }, types, callback }) => any, + unsubscribe: (subscription) => void, + publish?: (event) => void, +}; +``` + +:::note +**refine** uses these methods in [`useSubscription`](/api-references/hooks/live/useSubscription.md) and [`usePublish`](/api-references/hooks/live/usePublish.md). +::: + +:::tip +**refine** includes out-of-the-box live providers to use in your projects like: + +- **Ably** → [Source Code](https://github.com/pankod/refine/tree/master/packages/ably) - [Demo](https://codesandbox.io/s/refine-ably-example-u9wg9) +- **Supabase** → [Source Code](https://github.com/pankod/refine/tree/master/packages/supabase) - [Demo](https://codesandbox.io/s/refine-supabase-example-2zhty) + +::: + +## Usage + +You must pass a live provider to the `liveProvider` prop of ``. + +```tsx title="App.tsx" +import { Refine } from "@pankod/refine"; + +import liveProvider from "./liveProvider"; + +const App: React.FC = () => { + return ; +}; +``` + +## Creating a live provider + +We will build **"Ably Live Provider"** of [`@pankod/refine-ably`](https://github.com/pankod/refine/tree/master/packages/ably) from scratch to show the logic of how live provider methods interact with Ably. + +### `subscribe` + +This method is used to subscribe to a Realtime channel. **refine** subscribes to the related channels using subscribe method in supported hooks. This way it can be aware of data changes. + +```ts title="liveProvider.ts" +import { LiveProvider, LiveEvent } from "@pankod/refine"; +import Ably from "ably/promises"; +import { Types } from "ably"; + +interface MessageType extends Types.Message { + data: LiveEvent; +} + +const liveProvider = (client: Ably.Realtime): LiveProvider => { + return { + // highlight-start + subscribe: ({ channel, types, params, callback }) => { + const channelInstance = client.channels.get(channel); + + const listener = function (message: MessageType) { + if (types.includes("*") || types.includes(message.data.type)) { + if ( + message.data.type !== "created" && + params?.ids !== undefined && + message.data?.payload?.ids !== undefined + ) { + if ( + params.ids.filter((value) => + message.data.payload.ids!.includes(value), + ).length > 0 + ) { + callback(message.data as LiveEvent); + } + } else { + callback(message.data); + } + } + }; + channelInstance.subscribe(listener); + + return { channelInstance, listener }; + }, + // highlight-end + }; +}; +``` + +#### Parameter Types + +| Name | Type | Default | +| -------- | --------------------------------------------------------------------- | ------- | +| channel | `string` | | +| types | `Array<"deleted"` \| `"updated"` \| `"created"` \| "`*`" \| `string`> | `["*"]` | +| params | `{ids?: string[]; [key: string]: any;}` | | +| callback | `(event: LiveEvent) => void;` | | + +> [`LiveEvent`](api-references/interfaces.md#liveevent) + +#### Return Type + +| Type | +| ----- | +| `any` | + +:::important +The values returned from the `subscribe` method are passed to the `unsubscribe` method. Thus values needed for `unsubscription` must be returned from `subscribe` method. +::: + +
+ +**refine** will use this subscribe method in the [`useSubscription`](/api-references/hooks/live/useSubscription.md) hook. + +```ts +import { useSubscription } from "@pankod/refine"; + +useSubscription({ + channel: "channel-name", + onLiveEvent: (event) => {}, +}); +``` + +> [Refer to the useSubscription documentation for more information. →](/api-references/hooks/live/useSubscription.md) + +
+ +### `unsubscribe` + +This method is used to unsubscribe from a channel. The values returned from the `subscribe` method are passed to the `unsubscribe` method. + +```ts title="liveProvider.ts" +const liveProvider = (client: Ably.Realtime): LiveProvider => { + return { + // highlight-start + unsubscribe: (payload: { + channelInstance: Types.RealtimeChannelPromise; + listener: () => void; + }) => { + const { channelInstance, listener } = payload; + channelInstance.unsubscribe(listener); + }, + // highlight-end + }; +}; +``` + +:::caution +If you don't handle unsubscription it could lead to memory leaks. +::: + +#### Parameter Types + +| Name | Type | Description | +| ------------ | ----- | ---------------------------------------- | +| subscription | `any` | The values returned from the `subscribe` | + +#### Return Type + +| Type | +| ------ | +| `void` | + +
+ +### `publish` + +This method is used to publish an event on client side. Beware that publishing events on client side is not recommended and best practice is to publish events from server side. You can refer [Publish Events from API](#publish-events-from-api) to see which events must be published from the server. + +This `publish` is used in [realated hooks](#publish-events-from-hooks). When `publish` is used, subscribers to these events are notifyed. You can also publish your custom events using [`usePublish`](/api-references/hooks/live/usePublish.md). + +```ts title="liveProvider.ts" +const liveProvider = (client: Ably.Realtime): LiveProvider => { + return { + // highlight-start + publish: (event: LiveEvent) => { + const channelInstance = client.channels.get(event.channel); + + channelInstance.publish(event.type, event); + }, + // highlight-end + }; +}; +``` + +:::caution +If `publish` is used on client side you must handle the security of it by yourself. +::: + +#### Parameter Types + +| Name | Type | +| ----- | ----------- | +| event | `LiveEvent` | + +> [`LiveEvent`](api-references/interfaces.md#liveevent) + +#### Return Type + +| Type | +| ------ | +| `void` | + +
+ +**refine** will provide this publish method via the [`usePublish`](/api-references/hooks/live/usePublish.md) hook. + +```ts +import { usePublish } from "@pankod/refine"; + +const publish = usePublish(); +``` + +> [Refer to the usePublish documentation for more information. →](/api-references/hooks/live/usePublish.md) + +## `liveMode` + +`liveMode` must be passed to either `` or [supported hooks](#supported-hooks) for `liveProvider` to work. If it's not provided live features won't be activated. Passing it to `` configures it app wide and hooks will use this option. It can also be passed to hooks directly without passing to `` for detailed configuration. If both are provided value passed to the hook will override the value at ``. + +#### Usage in ``: + +```tsx title="App.tsx" +// ... + +const App: React.FC = () => { + return ; +}; +``` + +#### Usage in a hook: + +```tsx +const { data } = useList({ liveMode: "auto" }); +``` + +### `auto` + +Queries of related resource are invalidated in Realtime as new events from subscription arrive. +For example data from a `useTable` hook will be automatically updated when data is changed. + +### `manual` + +Queries of related resource are **not invalidated** in Realtime, instead [`onLiveEvent`](#onliveevent) is run with the `event` as new events from subscription arrive. +For example while in an edit form, it would be undesirable for data shown to change. `manual` mode can be used to prevent data from changing. + +### `off` + +Disables live mode. +For example it can be used to disable some parts of the app if you have app wide live mode configuration in ``. + +## `onLiveEvent` + +Callback that is run when new events from subscription arrive. It can be passed to both `` and [supported hooks](#supported-hooks). + +### `` + +`onLiveEvent` passed to `` will run every time when a new event occurs if `liveMode` is not `off`. It can be used for actions that are generally applicable to all events from active subscriptions. + +```tsx title="App.tsx" +// ... + +const App: React.FC = () => { + return ( + { + // Put your own logic based on event + }} + /> + ); +}; +``` + +### Hooks + +`onLiveEvent` passed to hooks runs when `liveMode` is not `off`. It is run with the event for related channel. + +```tsx +const { data } = useList({ + liveMode: "manual", + onLiveEvent: (event) => { + // Put your own logic based on event + }, +}); +``` + +## Supported Hooks + +| Supported data hooks | Supported form hooks | Supported other hooks | +| -------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| [`useList` →](api-references/hooks/data/useList.md) | [`useForm` →](api-references/hooks/form/useForm.md) | [`useTable` →](api-references/hooks/table/useTable.md) | +| [`useOne` →](api-references/hooks/data/useOne.md) | [`useModalForm` →](api-references/hooks/form/useModalForm.md) | [`useEditableTable` →](api-references/hooks/table/useEditableTable.md) | +| [`useMany` →](api-references/hooks/data/useMany.md) | [`useDrawerForm` →](api-references/hooks/form/useDrawerForm.md) | [`useSimpleList` →](api-references/hooks/show/useSimpleList.md) | +| | [`useStepsForm` →](api-references/hooks/form/useStepsForm.md) | [`useShow` →](api-references/hooks/show/useShow.md) | +| | | [`useCheckboxGroup` →](api-references/hooks/field/useCheckboxGroup.md) | +| | | [`useSelect` →](api-references/hooks/field/useSelect.md) | +| | | [`useRadioGroup` →](api-references/hooks/field/useRadioGroup.md) | + +## Supported Hooks Subscriptions + +Supported hooks subscribe in the following way: + +### `useList` + +```ts +useList({ resource: "posts" }); +``` + +```ts +{ + types: ["*"], + channel: "resources/posts" +} +``` + +:::tip +Following hooks uses `useList` under the hood and subscribe to same event. + +- [`useTable`](api-references/hooks/table/useTable.md) +- [`useEditableTable`](api-references/hooks/table/useEditableTable.md) +- [`useSimpleList`](api-references/hooks/show/useSimpleList.md) +- [`useCheckboxGroup`](api-references/hooks/field/useCheckboxGroup.md) +- [`useSelect`](api-references/hooks/field/useSelect.md) +- [`useRadioGroup`](api-references/hooks/field/useRadioGroup.md) + +::: + +### `useOne` + +```ts +useOne({ resource: "posts", id: "1" }); +``` + +```ts +{ + types: ["*"], + channel: "resources/posts", + params: { ids: ["1"] } +} +``` + +:::tip +Following hooks uses `useOne` under the hood and subscribe to same event. + +- [`useForm`](api-references/hooks/form/useForm.md) +- [`useModalForm`](api-references/hooks/form/useModalForm.md) +- [`useDrawerForm`](api-references/hooks/form/useDrawerForm.md) +- [`useStepsForm`](api-references/hooks/form/useStepsForm.md) +- [`useShow`](api-references/hooks/show/useShow.md) + +::: + +### `useMany` + +```ts +useMany({ resource: "posts", ids: ["1", "2"] }); +``` + +```ts +{ + types: ["*"], + channel: "resources/posts" + params: { ids: ["1", "2"] } +} +``` + +:::tip +Following hooks uses `useMany` under the hood and subscribe to same event. + +- [`useSelect`](api-references/hooks/field/useSelect.md) + +::: + +## Publish Events from Hooks + +**refine** publishes these events in the hooks. Let's see usage of hooks and what kind of events are published: + +### `useCreate` + +```ts +const { mutate } = useCreate(); + +mutate({ + resource: "posts", + values: { + title: "New Post", + }, +}); +``` + +```ts title="Published event" +{ + channel: `resources/posts`, + type: "created", + payload: { + ids: ["id-of-created-post"] + }, + date: new Date(), +} +``` + +### `useCreateMany` + +```ts +const { mutate } = useCreateMany(); + +mutate({ + resource: "posts", + values: [ + { + title: "New Post", + }, + { + title: "Another New Post", + }, + ], +}); +``` + +```ts title="Published event" +{ + channel: `resources/posts`, + type: "created", + payload: { + ids: ["id-of-new-post", "id-of-another-new-post"] + }, + date: new Date(), +} +``` + +### `useDelete` + +```ts +const { mutate } = useDelete(); + +mutate({ + resource: "posts", + id: "1", +}); +``` + +```ts title="Published event" +{ + channel: `resources/posts`, + type: "deleted", + payload: { + ids: ["1"] + }, + date: new Date(), +} +``` + +### `useDeleteMany` + +```ts +const { mutate } = useDeleteMany(); + +mutate({ + resource: "posts", + ids: ["1", "2"], +}); +``` + +```ts title="Published event" +{ + channel: `resources/posts`, + type: "deleted", + payload: { + ids: ["1", "2"] + }, + date: new Date(), +} +``` + +### `useUpdate` + +```ts +const { mutate } = useUpdate(); + +mutate({ + resource: "posts", + id: "2", + values: { title: "New Post Title" }, +}); +``` + +```ts title="Published event" +{ + channel: `resources/posts`, + type: "updated", + payload: { + ids: ["1"] + }, + date: new Date(), +} +``` + +### `useUpdateMany` + +```ts +const { mutate } = useUpdateMany(); + +mutate({ + resource: "posts", + ids: ["1", "2"], + values: { title: "New Post Title" }, +}); +``` + +```ts title="Published event" +{ + channel: `resources/posts`, + type: "updated", + payload: { + ids: ["1", "2"] + }, + date: new Date(), +} +``` + +## Publish Events from API + +Publishing in client side must be avoided generally. It's recommended to handle it in server side. Events published from the server must be in the following ways: + +- When creating a record: + +```ts +{ + channel: `resources/${resource}`, + type: "created", + payload: { + ids: [id] + }, + date: new Date(), +} +``` + +- When deleting a record: + +```ts +{ + channel: `resources/${resource}`, + type: "deleted", + payload: { + ids: [id] + }, + date: new Date(), +} +``` + +- When updating a record: + +```ts +{ + channel: `resources/${resource}`, + type: "updated", + payload: { + ids: [id] + }, + date: new Date(), +} +``` diff --git a/documentation/docs/guides-and-concepts/real-time.md b/documentation/docs/guides-and-concepts/real-time.md new file mode 100644 index 000000000000..b0761fcd9657 --- /dev/null +++ b/documentation/docs/guides-and-concepts/real-time.md @@ -0,0 +1,411 @@ +--- +id: real-time +title: Live / Realtime +--- + +import realTimeDemo from '@site/static/img/guides-and-concepts/real-time/real-time.gif'; +import manualMode from '@site/static/img/guides-and-concepts/real-time/manual-mode.gif'; +import customSider from '@site/static/img/guides-and-concepts/real-time/custom-sider.gif'; + +**refine** lets you add Realtime support to your app via `liveProvider` prop for [``](api-references/components/refine-config.md). It can be used to update and show data in Realtime throughout your app. **refine** remains agnostic in its API to allow different solutions([Ably](https://ably.com), [Socket.IO](https://socket.io/), [Mercure](https://mercure.rocks/), [supabase](https://supabase.com), etc.) to be integrated. + +[Refer to the Live Provider documentation for detailed information. →](api-references/providers/live-provider.md) + +We will be using [Ably](https://ably.com) in this guide to provide Realtime features. + +## Installation + +We need to install Ably live provider package from **refine**. + +```bash +npm install @pankod/refine-ably +``` + +## Setup + +Since we will need `apiKey` from Ably, you must first register and get the key from [Ably](https://ably.com). + +The app will have one resource: **posts** with [CRUD pages(list, create, edit and show) similar to base example](https://github.com/pankod/refine/tree/master/examples/base/src/pages/posts). + +[You can also refer to codesandbox to see final state of the app →](#live-condesandbox-example) + +## Adding `liveProvider` + +Firstly we create a ably client for [`@pankod/refine-ably`](https://github.com/pankod/refine/tree/master/packages/ably) live provider. + +```ts title="src/utility/ablyClient.ts" +import { Ably } from "@pankod/refine-ably"; + +export const ablyClient = new Ably.Realtime("your-api-key"); +``` + +Then pass `liveProvider` from [`@pankod/refine-ably`](https://github.com/pankod/refine/tree/master/packages/ably) to ``. + +```tsx title="src/App.tsx" +import { Refine } from "@pankod/refine"; +import dataProvider from "@pankod/refine-simple-rest"; +import routerProvider from "@pankod/refine-react-router"; +//highlight-next-line +import { liveProvider } from "@pankod/refine-ably"; + +//highlight-next-line +import { ablyClient } from "utility/ablyClient"; +import { PostList, PostCreate, PostEdit, PostShow } from "pages/posts"; + +const App: React.FC = () => { + return ( + + ); +}; + +export default App; +``` + +:::note + +For live features to work automatically we also added `liveMode="auto"`. + +[Refer to the Live Provider documentation for detailed information. →](api-references/providers/live-provider.md#livemode) +::: + +
+
+
+
+
+
+
+ Realtime Demo +
+ +## Configuring `liveMode` + +We may not want to make Realtime changes instantly in some cases. In these cases we can use `manual` mode to prevent the data changing instantly. Then we can handle the event manually. + +For example in an edit page for a record, It would be better to handle Realtime data manually to prevent synchronization problems caused by multiple editing sources. We would not want the data changing while we are trying to edit a record. + +We will be alerting about changes in an alert box on top of the form instead of changing the data instantly. + +```tsx title="src/pages/posts/edit.tsx" +// ... + +export const PostEdit: React.FC = () => { + //highlight-start + const [deprecated, setDeprecated] = + useState<"deleted" | "updated" | undefined>(); + //highlight-end + + const { formProps, saveButtonProps, queryResult } = useForm({ + //highlight-start + liveMode: "manual", + onLiveEvent: (event) => { + if (event.type === "deleted" || event.type === "updated") { + setDeprecated(event.type); + } + }, + //highlight-end + }); + + //highlight-start + const handleRefresh = () => { + queryResult?.refetch(); + setDeprecated(undefined); + }; + //highlight-end + + // ... + + return ( + + //highlight-start + {deprecated === "deleted" && ( + } + /> + )} + {deprecated === "updated" && ( + + } + /> + )} + //highlight-end +
+ // .... +
+
+ ); +}; +``` + +:::note + +We can also implement similar thing in show page. + +[Refer to the codesandbox example for detailed information. →](#live-condesandbox-example) +::: + +
+
+
+
+
+
+
+ Manual Mode Demo +
+ +## Custom Subscriptions + +You can subscribe to events emitted within **refine** in any place in your app with `useSubscription`. + +For example, we can subscribe to **_create_** event for **_posts_** resource and we can show a badge for number of events in the sider menu. + +Firstly, let's implement a custom sider like in [this example](/examples/customization/customSider.md). + +
+Custom Sider Menu + +```tsx title="src/components/sider.tsx" +import React, { useState } from "react"; +import { + AntdLayout, + Menu, + useMenu, + useTitle, + useNavigation, + Grid, + Icons, +} from "@pankod/refine"; +import { antLayoutSider, antLayoutSiderMobile } from "./styles"; + +export const CustomSider: React.FC = () => { + const [collapsed, setCollapsed] = useState(false); + const Title = useTitle(); + const { menuItems, selectedKey } = useMenu(); + const breakpoint = Grid.useBreakpoint(); + const { push } = useNavigation(); + + const isMobile = !breakpoint.lg; + + return ( + setCollapsed(collapsed)} + style={isMobile ? antLayoutSiderMobile : antLayoutSider} + > + + <Menu + selectedKeys={[selectedKey]} + mode="inline" + onClick={({ key }) => { + if (!breakpoint.lg) { + setCollapsed(true); + } + + push(key as string); + }} + > + {menuItems.map(({ icon, label, route }) => { + const isSelected = route === selectedKey; + return ( + <Menu.Item + style={{ + fontWeight: isSelected ? "bold" : "normal", + }} + key={route} + icon={icon} + > + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > + {label} + {!collapsed && isSelected && ( + <Icons.RightOutlined /> + )} + </div> + </Menu.Item> + ); + })} + </Menu> + </AntdLayout.Sider> + ); +}; +``` + +</details> + +Now, let's add a badge for number of create and update events for **_posts_** menu item. + +```tsx +import React, { useState } from "react"; +import { + AntdLayout, + Menu, + useMenu, + useTitle, + useNavigation, + Grid, + Icons, + //highlight-start + Badge, + useSubscription, + //highlight-end +} from "@pankod/refine"; +import { antLayoutSider, antLayoutSiderMobile } from "./styles"; + +export const CustomSider: React.FC = () => { + const [subscriptionCount, setSubscriptionCount] = useState(0); + const [collapsed, setCollapsed] = useState<boolean>(false); + const Title = useTitle(); + const { menuItems, selectedKey } = useMenu(); + const breakpoint = Grid.useBreakpoint(); + const { push } = useNavigation(); + + const isMobile = !breakpoint.lg; + + //highlight-start + useSubscription({ + channel: "resources/posts", + type: ["created", "updated"], + onLiveEvent: () => setSubscriptionCount((prev) => prev + 1), + }); + //highlight-end + + return ( + <AntdLayout.Sider + collapsible + collapsedWidth={isMobile ? 0 : 80} + collapsed={collapsed} + breakpoint="lg" + onCollapse={(collapsed: boolean): void => setCollapsed(collapsed)} + style={isMobile ? antLayoutSiderMobile : antLayoutSider} + > + <Title collapsed={collapsed} /> + <Menu + selectedKeys={[selectedKey]} + mode="inline" + onClick={({ key }) => { + if (!breakpoint.lg) { + setCollapsed(true); + } + + //highlight-start + if (key === "/posts") { + setSubscriptionCount(0); + } + //highlight-end + + push(key as string); + }} + > + {menuItems.map(({ icon, label, route }) => { + const isSelected = route === selectedKey; + return ( + <Menu.Item + style={{ + fontWeight: isSelected ? "bold" : "normal", + }} + key={route} + icon={icon} + > + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > + //highlight-start + <div> + {label} + {label === "Posts" && ( + <Badge + size="small" + count={subscriptionCount} + offset={[2, -15]} + /> + )} + </div> + //highlight-end + {!collapsed && isSelected && ( + <Icons.RightOutlined /> + )} + </div> + </Menu.Item> + ); + })} + </Menu> + </AntdLayout.Sider> + ); +}; +``` + +:::tip + +You can subscribe to specific `ids` with `params`. For example, you can subscribe to **deleted** and **updated** events from **posts** resource with **id** `1` and `2`. + +```tsx +useSubscription({ + channel: "resources/posts", + type: ["deleted", "updated"], + //highlight-start + params: { + ids: ["1", "2"], + }, + //highlight-end + onLiveEvent: () => setSubscriptionCount((prev) => prev + 1), +}); +``` + +::: + +<br/> +<div class="img-container"> + <div class="window"> + <div class="control red"></div> + <div class="control orange"></div> + <div class="control green"></div> + </div> + <img src={customSider} alt="Custom Sider Demo" /> +</div> + +## Live Condesandbox Example + +<iframe src="https://codesandbox.io/embed/refine-ably-example-u9wg9?autoresize=1&fontsize=14&module=%2Fsrc%2FApp.tsx&theme=dark&view=preview" + style={{width: "100%", height:"80vh", border: "0px", borderRadius: "8px", overflow:"hidden"}} + title="refine-ably-example" + allow="accelerometer; ambient-light-sensor; camera; encrypted-media; geolocation; gyroscope; hid; microphone; midi; payment; usb; vr; xr-spatial-tracking" + sandbox="allow-forms allow-modals allow-popups allow-presentation allow-same-origin allow-scripts" +></iframe> diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 52183e8d4909..6331a1c08b5b 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -19,6 +19,7 @@ module.exports = { items: [ "api-references/providers/auth-provider", "api-references/providers/data-provider", + "api-references/providers/live-provider", "api-references/providers/accessControl-provider", "api-references/providers/i18n-provider", "api-references/providers/router-provider", @@ -64,6 +65,14 @@ module.exports = { "api-references/hooks/accessControl/useCan", ], }, + { + type: "category", + label: "Live", + items: [ + "api-references/hooks/live/useSubscription", + "api-references/hooks/live/usePublish", + ], + }, { type: "category", label: "Form", @@ -233,6 +242,7 @@ module.exports = { }, "guides-and-concepts/ssr-nextjs", "guides-and-concepts/access-control", + "guides-and-concepts/real-time", { type: "category", label: "Upload", diff --git a/documentation/static/img/guides-and-concepts/real-time/custom-sider.gif b/documentation/static/img/guides-and-concepts/real-time/custom-sider.gif new file mode 100644 index 000000000000..e00dc9e9a2ae Binary files /dev/null and b/documentation/static/img/guides-and-concepts/real-time/custom-sider.gif differ diff --git a/documentation/static/img/guides-and-concepts/real-time/manual-mode.gif b/documentation/static/img/guides-and-concepts/real-time/manual-mode.gif new file mode 100644 index 000000000000..fa812d8cc896 Binary files /dev/null and b/documentation/static/img/guides-and-concepts/real-time/manual-mode.gif differ diff --git a/documentation/static/img/guides-and-concepts/real-time/real-time.gif b/documentation/static/img/guides-and-concepts/real-time/real-time.gif new file mode 100644 index 000000000000..b30b1681ac19 Binary files /dev/null and b/documentation/static/img/guides-and-concepts/real-time/real-time.gif differ diff --git a/examples/ably/.gitignore b/examples/ably/.gitignore new file mode 100644 index 000000000000..4d29575de804 --- /dev/null +++ b/examples/ably/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/ably/package.json b/examples/ably/package.json new file mode 100644 index 000000000000..be2234ae8fee --- /dev/null +++ b/examples/ably/package.json @@ -0,0 +1,38 @@ +{ + "name": "refine-ably-example", + "version": "0.1.0", + "private": true, + "dependencies": { + "@pankod/refine": "^2.0.7", + "@pankod/refine-ably": "^2.0.7", + "@pankod/refine-react-router": "^2.0.7", + "@pankod/refine-simple-rest": "^2.0.7", + "@types/node": "^12.20.11", + "@types/react": "^17.0.4", + "@types/react-dom": "^17.0.4", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-markdown": "^6.0.1", + "react-mde": "^11.1.0", + "react-scripts": "4.0.3", + "typescript": "^4.4.3", + "web-vitals": "^1.1.1" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "eject": "react-scripts eject" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} \ No newline at end of file diff --git a/examples/ably/public/favicon.ico b/examples/ably/public/favicon.ico new file mode 100644 index 000000000000..2f05c5f484fb Binary files /dev/null and b/examples/ably/public/favicon.ico differ diff --git a/examples/ably/public/index.html b/examples/ably/public/index.html new file mode 100644 index 000000000000..bae69a2d3b94 --- /dev/null +++ b/examples/ably/public/index.html @@ -0,0 +1,43 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta name="theme-color" content="#000000" /> + <meta + name="description" + content="Web site created using refine" + /> + <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> + <!-- + manifest.json provides metadata used when your web app is installed on a + user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ + --> + <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> + <!-- + Notice the use of %PUBLIC_URL% in the tags above. + It will be replaced with the URL of the `public` folder during the build. + Only files inside the `public` folder can be referenced from the HTML. + + Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will + work correctly both with client-side routing and a non-root public URL. + Learn how to configure a non-root public URL by running `npm run build`. + --> + <title>refine ably example + + + +
+ + + diff --git a/examples/ably/public/manifest.json b/examples/ably/public/manifest.json new file mode 100644 index 000000000000..325cc5a28c32 --- /dev/null +++ b/examples/ably/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "refine ably example", + "name": "refine ably example", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/ably/src/App.tsx b/examples/ably/src/App.tsx new file mode 100644 index 000000000000..289419c6c40b --- /dev/null +++ b/examples/ably/src/App.tsx @@ -0,0 +1,48 @@ +import { Refine } from "@pankod/refine"; +import dataProvider from "@pankod/refine-simple-rest"; +import { liveProvider } from "@pankod/refine-ably"; +import routerProvider from "@pankod/refine-react-router"; +import "@pankod/refine/dist/styles.min.css"; + +import { ablyClient } from "utility"; +import { CustomSider } from "components"; +import { PostList, PostCreate, PostEdit, PostShow } from "pages/posts"; +import { + CategoryList, + CategoryCreate, + CategoryEdit, + CategoryShow, +} from "pages/categories"; + +const API_URL = "https://api.fake-rest.refine.dev"; + +const App: React.FC = () => { + return ( + + ); +}; + +export default App; diff --git a/examples/ably/src/components/index.ts b/examples/ably/src/components/index.ts new file mode 100644 index 000000000000..624082852621 --- /dev/null +++ b/examples/ably/src/components/index.ts @@ -0,0 +1 @@ +export * from "./sider"; diff --git a/examples/ably/src/components/sider/index.tsx b/examples/ably/src/components/sider/index.tsx new file mode 100644 index 000000000000..b6985001c265 --- /dev/null +++ b/examples/ably/src/components/sider/index.tsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; +import { + AntdLayout, + Menu, + useMenu, + useTitle, + useNavigation, + Grid, + Icons, + Badge, + useSubscription, +} from "@pankod/refine"; +import { antLayoutSider, antLayoutSiderMobile } from "./styles"; + +export const CustomSider: React.FC = () => { + const [subscriptionCount, setSubscriptionCount] = useState(0); + const [collapsed, setCollapsed] = useState(false); + const Title = useTitle(); + const { menuItems, selectedKey } = useMenu(); + const breakpoint = Grid.useBreakpoint(); + const { push } = useNavigation(); + + const isMobile = !breakpoint.lg; + + useSubscription({ + channel: "resources/posts", + types: ["created", "updated"], + onLiveEvent: () => setSubscriptionCount((prev) => prev + 1), + }); + + return ( + setCollapsed(collapsed)} + style={isMobile ? antLayoutSiderMobile : antLayoutSider} + > + + <Menu + selectedKeys={[selectedKey]} + mode="inline" + onClick={({ key }) => { + if (!breakpoint.lg) { + setCollapsed(true); + } + + if (key === "/posts") { + setSubscriptionCount(0); + } + + push(key as string); + }} + > + {menuItems.map(({ icon, label, route }) => { + const isSelected = route === selectedKey; + return ( + <Menu.Item + style={{ + fontWeight: isSelected ? "bold" : "normal", + }} + key={route} + icon={icon} + > + <div + style={{ + display: "flex", + justifyContent: "space-between", + alignItems: "center", + }} + > + <div> + {label} + {label === "Posts" && ( + <Badge + size="small" + count={subscriptionCount} + offset={[2, -15]} + /> + )} + </div> + {!collapsed && isSelected && ( + <Icons.RightOutlined /> + )} + </div> + </Menu.Item> + ); + })} + </Menu> + </AntdLayout.Sider> + ); +}; diff --git a/examples/ably/src/components/sider/styles.ts b/examples/ably/src/components/sider/styles.ts new file mode 100644 index 000000000000..182213d17bc6 --- /dev/null +++ b/examples/ably/src/components/sider/styles.ts @@ -0,0 +1,10 @@ +import { CSSProperties } from "react"; + +export const antLayoutSider: CSSProperties = { + position: "relative", +}; +export const antLayoutSiderMobile: CSSProperties = { + position: "fixed", + height: "100vh", + zIndex: 999, +}; diff --git a/examples/ably/src/index.tsx b/examples/ably/src/index.tsx new file mode 100644 index 000000000000..01f687f90595 --- /dev/null +++ b/examples/ably/src/index.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./App"; + +ReactDOM.render( + <React.StrictMode> + <App /> + </React.StrictMode>, + document.getElementById("root"), +); diff --git a/examples/ably/src/interfaces/index.d.ts b/examples/ably/src/interfaces/index.d.ts new file mode 100644 index 000000000000..3c805f2d5919 --- /dev/null +++ b/examples/ably/src/interfaces/index.d.ts @@ -0,0 +1,12 @@ +export interface ICategory { + id: string; + title: string; +} + +export interface IPost { + id: string; + title: string; + content: string; + status: "published" | "draft" | "rejected"; + category: ICategory; +} diff --git a/examples/ably/src/pages/categories/create.tsx b/examples/ably/src/pages/categories/create.tsx new file mode 100644 index 000000000000..3dcac89f1ea9 --- /dev/null +++ b/examples/ably/src/pages/categories/create.tsx @@ -0,0 +1,31 @@ +import { + Create, + Form, + Input, + IResourceComponentsProps, + useForm, +} from "@pankod/refine"; + +import { ICategory } from "interfaces"; + +export const CategoryCreate: React.FC<IResourceComponentsProps> = () => { + const { formProps, saveButtonProps } = useForm<ICategory>(); + + return ( + <Create saveButtonProps={saveButtonProps}> + <Form {...formProps} layout="vertical"> + <Form.Item + label="Title" + name="title" + rules={[ + { + required: true, + }, + ]} + > + <Input /> + </Form.Item> + </Form> + </Create> + ); +}; diff --git a/examples/ably/src/pages/categories/edit.tsx b/examples/ably/src/pages/categories/edit.tsx new file mode 100644 index 000000000000..3bd7557a8e19 --- /dev/null +++ b/examples/ably/src/pages/categories/edit.tsx @@ -0,0 +1,31 @@ +import { + Edit, + Form, + Input, + IResourceComponentsProps, + useForm, +} from "@pankod/refine"; + +import { ICategory } from "interfaces"; + +export const CategoryEdit: React.FC<IResourceComponentsProps> = () => { + const { formProps, saveButtonProps } = useForm<ICategory>(); + + return ( + <Edit saveButtonProps={saveButtonProps}> + <Form {...formProps} layout="vertical"> + <Form.Item + label="Title" + name="title" + rules={[ + { + required: true, + }, + ]} + > + <Input /> + </Form.Item> + </Form> + </Edit> + ); +}; diff --git a/examples/ably/src/pages/categories/index.tsx b/examples/ably/src/pages/categories/index.tsx new file mode 100644 index 000000000000..9da022ffe482 --- /dev/null +++ b/examples/ably/src/pages/categories/index.tsx @@ -0,0 +1,4 @@ +export * from "./list"; +export * from "./create"; +export * from "./edit"; +export * from "./show"; diff --git a/examples/ably/src/pages/categories/list.tsx b/examples/ably/src/pages/categories/list.tsx new file mode 100644 index 000000000000..e1448ef15b4e --- /dev/null +++ b/examples/ably/src/pages/categories/list.tsx @@ -0,0 +1,55 @@ +import { + List, + Table, + useTable, + IResourceComponentsProps, + Space, + EditButton, + DateField, + DeleteButton, +} from "@pankod/refine"; + +import { ICategory } from "interfaces"; + +export const CategoryList: React.FC<IResourceComponentsProps> = () => { + const { tableProps } = useTable<ICategory>(); + + return ( + <List> + <Table {...tableProps} rowKey="id"> + <Table.Column dataIndex="id" title="ID" /> + <Table.Column + dataIndex="title" + title="Title" + key="title" + sorter + /> + <Table.Column + dataIndex="createdAt" + key="createdAt" + title="Created At" + render={(value) => <DateField value={value} format="LLL" />} + sorter + /> + <Table.Column<ICategory> + title="Actions" + dataIndex="actions" + render={(_, record) => ( + <Space> + <EditButton + size="small" + hideText + recordItemId={record.id} + /> + <DeleteButton + size="small" + hideText + recordItemId={record.id} + /> + </Space> + )} + /> + </Table> + </List> + ); +}; diff --git a/examples/ably/src/pages/categories/show.tsx b/examples/ably/src/pages/categories/show.tsx new file mode 100644 index 000000000000..f788d271e200 --- /dev/null +++ b/examples/ably/src/pages/categories/show.tsx @@ -0,0 +1,26 @@ +import { + useShow, + Show, + Typography, + IResourceComponentsProps, +} from "@pankod/refine"; + +import { ICategory } from "interfaces"; + +const { Title, Text } = Typography; + +export const CategoryShow: React.FC<IResourceComponentsProps> = () => { + const { queryResult } = useShow<ICategory>(); + const { data, isLoading } = queryResult; + const record = data?.data; + + return ( + <Show isLoading={isLoading}> + <Title level={5}>Id + {record?.id} + + Title + {record?.title} + + ); +}; diff --git a/examples/ably/src/pages/posts/create.tsx b/examples/ably/src/pages/posts/create.tsx new file mode 100644 index 000000000000..063392730934 --- /dev/null +++ b/examples/ably/src/pages/posts/create.tsx @@ -0,0 +1,102 @@ +import React, { useState } from "react"; +import { + Create, + Form, + Input, + IResourceComponentsProps, + Select, + useForm, + useSelect, +} from "@pankod/refine"; + +import ReactMarkdown from "react-markdown"; +import ReactMde from "react-mde"; + +import "react-mde/lib/styles/css/react-mde-all.css"; + +import { IPost, ICategory } from "interfaces"; + +export const PostCreate: React.FC = () => { + const { formProps, saveButtonProps } = useForm(); + + const { selectProps: categorySelectProps } = useSelect({ + resource: "categories", + }); + + const [selectedTab, setSelectedTab] = + useState<"write" | "preview">("write"); + + return ( + +
+ + + + + + + + + Promise.resolve( + {markdown}, + ) + } + /> + +
+
+ ); +}; diff --git a/examples/ably/src/pages/posts/edit.tsx b/examples/ably/src/pages/posts/edit.tsx new file mode 100644 index 000000000000..39bd314806c9 --- /dev/null +++ b/examples/ably/src/pages/posts/edit.tsx @@ -0,0 +1,156 @@ +import React, { useState } from "react"; +import { + Alert, + Edit, + Form, + Input, + IResourceComponentsProps, + ListButton, + RefreshButton, + Select, + useForm, + useSelect, +} from "@pankod/refine"; + +import ReactMarkdown from "react-markdown"; +import ReactMde from "react-mde"; + +import "react-mde/lib/styles/css/react-mde-all.css"; + +import { IPost, ICategory } from "interfaces"; + +export const PostEdit: React.FC = () => { + const [deprecated, setDeprecated] = + useState<"deleted" | "updated" | undefined>(); + + const { formProps, saveButtonProps, queryResult } = useForm({ + liveMode: "manual", + onLiveEvent: (event) => { + if (event.type === "deleted" || event.type === "updated") { + setDeprecated(event.type); + } + }, + }); + + const postData = queryResult?.data?.data; + const { selectProps: categorySelectProps } = useSelect({ + resource: "categories", + defaultValue: postData?.category.id, + }); + + const [selectedTab, setSelectedTab] = + useState<"write" | "preview">("write"); + + const handleRefresh = () => { + queryResult?.refetch(); + setDeprecated(undefined); + }; + + return ( + + + + + ), + }} + > + {deprecated === "deleted" && ( + } + /> + )} + + {deprecated === "updated" && ( + + } + /> + )} + +
+ + + + + + + + + Promise.resolve( + {markdown}, + ) + } + /> + +
+
+ ); +}; diff --git a/examples/ably/src/pages/posts/index.tsx b/examples/ably/src/pages/posts/index.tsx new file mode 100644 index 000000000000..9da022ffe482 --- /dev/null +++ b/examples/ably/src/pages/posts/index.tsx @@ -0,0 +1,4 @@ +export * from "./list"; +export * from "./create"; +export * from "./edit"; +export * from "./show"; diff --git a/examples/ably/src/pages/posts/list.tsx b/examples/ably/src/pages/posts/list.tsx new file mode 100644 index 000000000000..f379cc3230c9 --- /dev/null +++ b/examples/ably/src/pages/posts/list.tsx @@ -0,0 +1,107 @@ +import { + List, + Table, + TextField, + useTable, + IResourceComponentsProps, + Space, + EditButton, + ShowButton, + useMany, + FilterDropdown, + useSelect, + Select, + Radio, + TagField, +} from "@pankod/refine"; + +import { IPost, ICategory } from "interfaces"; + +export const PostList: React.FC = () => { + const { tableProps } = useTable(); + + const categoryIds = + tableProps?.dataSource?.map((item) => item.category.id) ?? []; + const { data, isLoading } = useMany({ + resource: "categories", + ids: categoryIds, + queryOptions: { + enabled: categoryIds.length > 0, + }, + }); + + const { selectProps: categorySelectProps } = useSelect({ + resource: "categories", + optionLabel: "title", + optionValue: "id", + }); + + return ( + + + + + { + if (isLoading) { + return ; + } + + return ( + item.id === value) + ?.title + } + /> + ); + }} + filterDropdown={(props) => ( + +
+
+ ); +}; diff --git a/examples/ably/src/pages/posts/show.tsx b/examples/ably/src/pages/posts/show.tsx new file mode 100644 index 000000000000..da0febbc2ee7 --- /dev/null +++ b/examples/ably/src/pages/posts/show.tsx @@ -0,0 +1,103 @@ +import { + useShow, + Show, + Typography, + IResourceComponentsProps, + useOne, + MarkdownField, + Alert, + DeleteButton, + ListButton, + EditButton, + RefreshButton, +} from "@pankod/refine"; + +import { IPost, ICategory } from "interfaces"; +import { useState } from "react"; + +const { Title, Text } = Typography; + +export const PostShow: React.FC = () => { + const [deprecated, setDeprecated] = + useState<"deleted" | "updated" | undefined>(); + + const { queryResult } = useShow({ + liveMode: "manual", + onLiveEvent: (event) => { + if (event.type === "deleted" || event.type === "updated") { + setDeprecated(event.type); + } + }, + }); + + const { data, isLoading } = queryResult; + const record = data?.data; + + const { data: categoryData, isLoading: categoryIsLoading } = + useOne({ + resource: "categories", + id: record?.category.id || "", + queryOptions: { + enabled: !!record, + }, + }); + + const handleRefresh = () => { + queryResult?.refetch(); + setDeprecated(undefined); + }; + + return ( + + + + + + + ), + }} + > + {deprecated === "deleted" && ( + } + /> + )} + + {deprecated === "updated" && ( + + } + /> + )} + + Id + {record?.id} + + Title + {record?.title} + + Category + + {categoryIsLoading ? "Loading..." : categoryData?.data.title} + + + Content + + + ); +}; diff --git a/examples/ably/src/react-app-env.d.ts b/examples/ably/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/examples/ably/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/ably/src/utility/ablyClient.ts b/examples/ably/src/utility/ablyClient.ts new file mode 100644 index 000000000000..0555a3c541ab --- /dev/null +++ b/examples/ably/src/utility/ablyClient.ts @@ -0,0 +1,5 @@ +import { Ably } from "@pankod/refine-ably"; + +export const ablyClient = new Ably.Realtime( + "syVQsA.ofJCQg:GvXwhLsJhjMo4onQ_zQKjvb9biBIXMiDd7qLo9ZVA38", +); diff --git a/examples/ably/src/utility/index.ts b/examples/ably/src/utility/index.ts new file mode 100644 index 000000000000..099a5907fb14 --- /dev/null +++ b/examples/ably/src/utility/index.ts @@ -0,0 +1 @@ +export * from "./ablyClient"; diff --git a/examples/ably/tsconfig.json b/examples/ably/tsconfig.json new file mode 100644 index 000000000000..d2bb8e24aed3 --- /dev/null +++ b/examples/ably/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/examples/dataProvider/supabase/src/App.tsx b/examples/dataProvider/supabase/src/App.tsx index 33ad1caf8988..ae02ae44b382 100644 --- a/examples/dataProvider/supabase/src/App.tsx +++ b/examples/dataProvider/supabase/src/App.tsx @@ -1,5 +1,5 @@ import { Refine, AuthProvider } from "@pankod/refine"; -import { dataProvider } from "@pankod/refine-supabase"; +import { dataProvider, liveProvider } from "@pankod/refine-supabase"; import routerProvider from "@pankod/refine-react-router"; import "@pankod/refine/dist/styles.min.css"; @@ -65,6 +65,7 @@ const App: React.FC = () => { return ( { show: PostShow, }, ]} + liveMode="auto" /> ); }; diff --git a/examples/dataProvider/supabase/src/pages/posts/edit.tsx b/examples/dataProvider/supabase/src/pages/posts/edit.tsx index b1056f71e083..3e4291f74155 100644 --- a/examples/dataProvider/supabase/src/pages/posts/edit.tsx +++ b/examples/dataProvider/supabase/src/pages/posts/edit.tsx @@ -1,10 +1,14 @@ import React, { useState } from "react"; import { + Alert, + Button, Edit, Form, Input, IResourceComponentsProps, + ListButton, RcFile, + RefreshButton, Select, Upload, useForm, @@ -20,7 +24,13 @@ import { IPost, ICategory } from "interfaces"; import { supabaseClient, normalizeFile } from "utility"; export const PostEdit: React.FC = () => { - const { formProps, saveButtonProps, queryResult } = useForm(); + const [isDeprecated, setIsDeprecated] = useState(false); + const { formProps, saveButtonProps, queryResult } = useForm({ + liveMode: "manual", + onLiveEvent: () => { + setIsDeprecated(true); + }, + }); const postData = queryResult?.data?.data; const { selectProps: categorySelectProps } = useSelect({ @@ -31,8 +41,42 @@ export const PostEdit: React.FC = () => { const [selectedTab, setSelectedTab] = useState<"write" | "preview">("write"); + const handleRefresh = () => { + queryResult?.refetch(); + setIsDeprecated(false); + }; + return ( - + + + + + ), + }} + > + {isDeprecated && ( + + Refresh + + } + /> + )} +
= () => { const categoryIds = tableProps?.dataSource?.map((item) => item.categoryId) ?? []; + const { data, isLoading } = useMany({ resource: "categories", ids: categoryIds, diff --git a/examples/dataProvider/supabase/src/pages/posts/show.tsx b/examples/dataProvider/supabase/src/pages/posts/show.tsx index c5d6a99ac875..df23d9e4e529 100644 --- a/examples/dataProvider/supabase/src/pages/posts/show.tsx +++ b/examples/dataProvider/supabase/src/pages/posts/show.tsx @@ -7,14 +7,28 @@ import { MarkdownField, Space, ImageField, + Alert, + Button, + ListButton, + EditButton, + RefreshButton, } from "@pankod/refine"; import { IPost, ICategory } from "interfaces"; +import { useState } from "react"; const { Title, Text } = Typography; export const PostShow: React.FC = () => { - const { queryResult } = useShow(); + const [isDeprecated, setIsDeprecated] = useState(false); + + const { queryResult } = useShow({ + liveMode: "manual", + onLiveEvent: () => { + setIsDeprecated(true); + }, + }); + const { data, isLoading } = queryResult; const record = data?.data; @@ -27,8 +41,43 @@ export const PostShow: React.FC = () => { }, }); + const handleRefresh = () => { + queryResult.refetch(); + setIsDeprecated(false); + }; + return ( - + + + + + + ), + }} + > + {isDeprecated && ( + + Refresh + + } + /> + )} + Id {record?.id} diff --git a/package.json b/package.json index a0efc5b61253..2f4fe5c53686 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "bootstrap": "lerna bootstrap --hoist --strict --ignore-scripts --ignore @pankod/refine-codemod && cd packages/codemod && npm i", "lint": "eslint -c ./.eslintrc packages examples", "lint:fix": "npm run lint -- --quiet --fix", - "build": "lerna run build", + "build": "lerna run build --ignore @pankod/refine-codemod", "versionup": "npm run lerna version --conventional-commits --no-git-tag-version", "nuke": "rm -rf node_modules; for d in for d in packages/*/node_modules; do echo $d; rm -rf $d; done; for d in for d in packages/*/dist; do echo $d; rm -rf $d; done; for d in packages/*/dist; do echo $d; rm -rf $d; done; for d in examples/*/node_modules; do echo $d; rm -rf $d; done; for d in examples/*/package-lock.json; do echo $d; rm -rf $d; done; for d in packages/*/package-lock.json; do echo $d; rm -rf $d; done;" }, diff --git a/packages/ably/LICENSE b/packages/ably/LICENSE new file mode 100644 index 000000000000..9dc92e6236cc --- /dev/null +++ b/packages/ably/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Pankod + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/ably/README.md b/packages/ably/README.md new file mode 100644 index 000000000000..8d8680039d9c --- /dev/null +++ b/packages/ably/README.md @@ -0,0 +1,26 @@ + +
+
refine is a React-based framework for building data-intensive applications in no time ✨ It ships with Ant Design System, an enterprise-level UI toolkit.
+
+ +
+ Created by Pankod +
+ +## About + +[**refine**](https://refine.dev/) offers lots of out-of-the box functionality for rapid development, without compromising extreme customizability. Use-cases include, but are not limited to *admin panels*, *B2B applications* and *dashboards*. + +## Documentation + +For more detailed information and usage, refer to the [refine data provider documentation](https://refine.dev/docs/api-references/providers/data-provider). + +## Install + +``` +npm install @pankod/refine-supabase +``` diff --git a/packages/ably/jest.config.js b/packages/ably/jest.config.js new file mode 100644 index 000000000000..df9dab749854 --- /dev/null +++ b/packages/ably/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: "ts-jest", + rootDir: "./", + name: "ably", + displayName: "ably", +}; diff --git a/packages/ably/package.json b/packages/ably/package.json new file mode 100644 index 000000000000..896bbd032cff --- /dev/null +++ b/packages/ably/package.json @@ -0,0 +1,51 @@ +{ + "name": "@pankod/refine-ably", + "description": "refine ably live provider. refine is a React-based framework for building data-intensive applications in no time. It ships with Ant Design System, an enterprise-level UI toolkit.", + "version": "2.0.12", + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "private": false, + "files": [ + "dist", + "src" + ], + "engines": { + "node": ">=10" + }, + "scripts": { + "start": "tsdx watch --tsconfig tsconfig.json --verbose --noClean", + "build": "tsdx build --tsconfig tsconfig.json", + "test": "tsdx test --passWithNoTests --runInBand", + "prepare": "tsdx build", + "size": "size-limit", + "analyze": "size-limit --why" + }, + "author": "Pankod", + "module": "dist/refine-ably.esm.js", + "size-limit": [ + { + "path": "dist/refine-ably.cjs.production.min.js", + "limit": "10 KB" + }, + { + "path": "dist/refine-ably.esm.js", + "limit": "10 KB" + } + ], + "devDependencies": { + "@size-limit/preset-small-lib": "^5.0.5", + "nock": "^13.1.3", + "size-limit": "^5.0.5", + "tsdx": "^0.14.1", + "tslib": "^2.3.1" + }, + "dependencies": { + "@pankod/refine": "^2.2.3", + "ably": "^1.2.15" + }, + "gitHead": "829f5a516f98c06f666d6be3e6e6099c75c07719", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/ably/src/index.ts b/packages/ably/src/index.ts new file mode 100644 index 000000000000..03151b2672b1 --- /dev/null +++ b/packages/ably/src/index.ts @@ -0,0 +1,53 @@ +import { LiveProvider, LiveEvent } from "@pankod/refine"; +import Ably from "ably/promises"; +import { Types } from "ably"; +interface MessageType extends Types.Message { + data: LiveEvent; +} + +const liveProvider = (client: Ably.Realtime): LiveProvider => { + return { + subscribe: ({ channel, types, params, callback }) => { + const channelInstance = client.channels.get(channel); + + const listener = function (message: MessageType) { + if (types.includes("*") || types.includes(message.data.type)) { + if ( + message.data.type !== "created" && + params?.ids !== undefined && + message.data?.payload?.ids !== undefined + ) { + if ( + params.ids.filter((value) => + message.data.payload.ids!.includes(value), + ).length > 0 + ) { + callback(message.data as LiveEvent); + } + } else { + callback(message.data); + } + } + }; + channelInstance.subscribe(listener); + + return { channelInstance, listener }; + }, + + unsubscribe: (payload: { + channelInstance: Types.RealtimeChannelPromise; + listener: () => void; + }) => { + const { channelInstance, listener } = payload; + channelInstance.unsubscribe(listener); + }, + + publish: (event: LiveEvent) => { + const channelInstance = client.channels.get(event.channel); + + channelInstance.publish(event.type, event); + }, + }; +}; + +export { liveProvider, Ably }; diff --git a/packages/ably/tsconfig.json b/packages/ably/tsconfig.json new file mode 100644 index 000000000000..08c741192950 --- /dev/null +++ b/packages/ably/tsconfig.json @@ -0,0 +1,11 @@ +{ + "include": [ + "src", + "types" + ], + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": ".", + } +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 8a2af3ee8b91..4da8aabcf234 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@ant-design/icons": "^4.5.0", + "@supabase/supabase-js": "^1.22.4", "antd": "^4.17.1", "dayjs": "^1.10.7", "export-to-csv": "^0.2.1", diff --git a/packages/core/src/components/buttons/refresh/index.tsx b/packages/core/src/components/buttons/refresh/index.tsx index 41d214792a04..db6802089663 100644 --- a/packages/core/src/components/buttons/refresh/index.tsx +++ b/packages/core/src/components/buttons/refresh/index.tsx @@ -50,6 +50,7 @@ export const RefreshButton: FC = ({ enabled: false, }, metaData, + liveMode: "off", }); return ( diff --git a/packages/core/src/components/containers/refine/index.tsx b/packages/core/src/components/containers/refine/index.tsx index b239dc75bd4c..aa61e4dff24d 100644 --- a/packages/core/src/components/containers/refine/index.tsx +++ b/packages/core/src/components/containers/refine/index.tsx @@ -14,6 +14,7 @@ import { ReactQueryDevtools } from "react-query/devtools"; import { AuthContextProvider } from "@contexts/auth"; import { DataContextProvider } from "@contexts/data"; +import { LiveContextProvider } from "@contexts/live"; import { defaultProvider, TranslationContextProvider, @@ -38,6 +39,8 @@ import { TitleProps, IRouterProvider, ResourceProps, + ILiveContext, + LiveModeProps, IAccessControlContext, } from "../../../interfaces"; @@ -51,6 +54,7 @@ interface IResource extends IResourceItem, ResourceProps {} export interface RefineProps { authProvider?: IAuthContext; dataProvider: IDataContextProvider; + liveProvider?: ILiveContext; routerProvider: IRouterProvider; accessControlProvider?: IAccessControlContext; resources?: IResource[]; @@ -73,6 +77,8 @@ export interface RefineProps { reactQueryClientConfig?: QueryClientConfig; notifcationConfig?: ConfigProps; reactQueryDevtoolConfig?: any; + liveMode?: LiveModeProps["liveMode"]; + onLiveEvent?: LiveModeProps["onLiveEvent"]; } /** @@ -93,6 +99,7 @@ export const Refine: React.FC = ({ LoginPage, catchAll, children, + liveProvider, i18nProvider = defaultProvider.i18nProvider, mutationMode = "pessimistic", syncWithLocation = false, @@ -108,6 +115,8 @@ export const Refine: React.FC = ({ reactQueryClientConfig, reactQueryDevtoolConfig, notifcationConfig, + liveMode, + onLiveEvent, }) => { const queryClient = new QueryClient({ ...reactQueryClientConfig, @@ -152,57 +161,69 @@ export const Refine: React.FC = ({ - - - - + + + - - - - - <> - {children} - {RouterComponent ? ( - + + + + + + <> + {children} + {RouterComponent ? ( + + + + ) : ( - - ) : ( - - )} - - - - - - - - - + )} + + + + + + + + + + { export const DataContext = React.createContext( defaultDataProvider() as IDataContext, ); + export const DataContextProvider: React.FC = ({ getList, getMany, diff --git a/packages/core/src/contexts/live/ILiveContext.ts b/packages/core/src/contexts/live/ILiveContext.ts new file mode 100644 index 000000000000..5dbab431e41c --- /dev/null +++ b/packages/core/src/contexts/live/ILiveContext.ts @@ -0,0 +1,21 @@ +import { LiveEvent } from "../../interfaces"; + +export type ILiveContext = + | { + publish?: (event: LiveEvent) => void; + subscribe: (options: { + channel: string; + params?: { + ids?: string[]; + [key: string]: any; + }; + types: LiveEvent["type"][]; + callback: (event: LiveEvent) => void; + }) => any; + unsubscribe: (subscription: any) => void; + } + | undefined; + +export type ILiveContextProvider = { + liveProvider: ILiveContext; +}; diff --git a/packages/core/src/contexts/live/index.tsx b/packages/core/src/contexts/live/index.tsx new file mode 100644 index 000000000000..33362e5868e6 --- /dev/null +++ b/packages/core/src/contexts/live/index.tsx @@ -0,0 +1,16 @@ +import React from "react"; + +import { ILiveContext, ILiveContextProvider } from "./ILiveContext"; + +export const LiveContext = React.createContext(undefined); + +export const LiveContextProvider: React.FC = ({ + liveProvider, + children, +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/core/src/contexts/refine/IRefineContext.ts b/packages/core/src/contexts/refine/IRefineContext.ts index 4c525847fe68..caa616db2a25 100644 --- a/packages/core/src/contexts/refine/IRefineContext.ts +++ b/packages/core/src/contexts/refine/IRefineContext.ts @@ -1,6 +1,11 @@ import React from "react"; -import { MutationMode, TitleProps, LayoutProps } from "../../interfaces"; +import { + MutationMode, + TitleProps, + LayoutProps, + LiveModeProps, +} from "../../interfaces"; export interface IRefineContext { hasDashboard: boolean; @@ -17,6 +22,8 @@ export interface IRefineContext { Header: React.FC; Footer: React.FC; OffLayoutArea: React.FC; + liveMode?: LiveModeProps["liveMode"]; + onLiveEvent?: LiveModeProps["onLiveEvent"]; } export interface IRefineContextProvider { @@ -34,4 +41,6 @@ export interface IRefineContextProvider { Header?: React.FC; Footer?: React.FC; OffLayoutArea?: React.FC; + liveMode?: LiveModeProps["liveMode"]; + onLiveEvent?: LiveModeProps["onLiveEvent"]; } diff --git a/packages/core/src/contexts/refine/index.tsx b/packages/core/src/contexts/refine/index.tsx index 34ea01f6937d..4a758e611a30 100644 --- a/packages/core/src/contexts/refine/index.tsx +++ b/packages/core/src/contexts/refine/index.tsx @@ -24,6 +24,8 @@ export const RefineContext = React.createContext({ Footer: DefaultFooter, Layout: DefaultLayout, OffLayoutArea: DefaultOffLayoutArea, + liveMode: "off", + onLiveEvent: undefined, }); export const RefineContextProvider: React.FC = ({ @@ -42,6 +44,8 @@ export const RefineContextProvider: React.FC = ({ OffLayoutArea = DefaultOffLayoutArea, LoginPage = DefaultLoginPage, catchAll, + liveMode = "off", + onLiveEvent, }) => { return ( = ({ DashboardPage, LoginPage, catchAll, + liveMode, + onLiveEvent, }} > {children} diff --git a/packages/core/src/hooks/data/useCreate.spec.tsx b/packages/core/src/hooks/data/useCreate.spec.tsx index 8bbcf4be8c84..7ba206e4b372 100644 --- a/packages/core/src/hooks/data/useCreate.spec.tsx +++ b/packages/core/src/hooks/data/useCreate.spec.tsx @@ -29,4 +29,43 @@ describe("useCreate Hook", () => { expect(status).toBe("success"); expect(data?.data.slug).toBe("ut-ad-et"); }); + + describe("usePublish", () => { + it("publish live event on success", async () => { + const onPublishMock = jest.fn(); + + const { result, waitForNextUpdate, waitFor } = renderHook( + () => useCreate(), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: jest.fn(), + publish: onPublishMock, + }, + }), + }, + ); + + result.current.mutate({ resource: "posts", values: { id: 1 } }); + + await waitForNextUpdate(); + + await waitFor(() => { + return result.current.isSuccess; + }); + + expect(onPublishMock).toBeCalled(); + expect(onPublishMock).toHaveBeenCalledWith({ + channel: "resources/posts", + date: expect.any(Date), + type: "created", + payload: { + ids: ["1"], + }, + }); + }); + }); }); diff --git a/packages/core/src/hooks/data/useCreate.ts b/packages/core/src/hooks/data/useCreate.ts index 3b0c192e0582..0dff9f01e072 100644 --- a/packages/core/src/hooks/data/useCreate.ts +++ b/packages/core/src/hooks/data/useCreate.ts @@ -11,7 +11,12 @@ import { SuccessErrorNotification, MetaDataQuery, } from "../../interfaces"; -import { useTranslate, useCheckError, useCacheQueries } from "@hooks"; +import { + useTranslate, + useCheckError, + useCacheQueries, + usePublish, +} from "@hooks"; import { handleNotification } from "@definitions"; type useCreateParams = { @@ -53,6 +58,7 @@ export const useCreate = < const getAllQueries = useCacheQueries(); const translate = useTranslate(); const queryClient = useQueryClient(); + const publish = usePublish(); const mutation = useMutation< CreateResponse, @@ -68,7 +74,7 @@ export const useCreate = < }), { onSuccess: ( - _, + data, { resource, successNotification: successNotificationFromProp }, ) => { const resourceSingular = pluralize.singular(resource); @@ -90,6 +96,18 @@ export const useCreate = < getAllQueries(resource).forEach((query) => { queryClient.invalidateQueries(query.queryKey); + console.log("query, ", query); + }); + + publish?.({ + channel: `resources/${resource}`, + type: "created", + payload: { + ids: data.data?.id + ? [data.data.id.toString()] + : undefined, + }, + date: new Date(), }); }, onError: ( diff --git a/packages/core/src/hooks/data/useCreateMany.spec.tsx b/packages/core/src/hooks/data/useCreateMany.spec.tsx index 3946abcec288..20fbdc1e88c6 100644 --- a/packages/core/src/hooks/data/useCreateMany.spec.tsx +++ b/packages/core/src/hooks/data/useCreateMany.spec.tsx @@ -30,4 +30,46 @@ describe("useCreateMany Hook", () => { expect(data?.data[0].slug).toBe("ut-ad-et"); expect(data?.data[1].slug).toBe("consequatur-molestiae-rerum"); }); + + describe("usePublish", () => { + it("publish live event on success", async () => { + const onPublishMock = jest.fn(); + + const { result, waitForNextUpdate, waitFor } = renderHook( + () => useCreateMany(), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: jest.fn(), + publish: onPublishMock, + }, + }), + }, + ); + + result.current.mutate({ + resource: "posts", + values: [{ id: 1 }, { id: 2 }], + }); + + await waitForNextUpdate(); + + await waitFor(() => { + return result.current.isSuccess; + }); + + expect(onPublishMock).toBeCalled(); + expect(onPublishMock).toHaveBeenCalledWith({ + channel: "resources/posts", + date: expect.any(Date), + type: "created", + payload: { + ids: ["1", "2"], + }, + }); + }); + }); }); diff --git a/packages/core/src/hooks/data/useCreateMany.ts b/packages/core/src/hooks/data/useCreateMany.ts index 87026738605f..c8f431bebf72 100644 --- a/packages/core/src/hooks/data/useCreateMany.ts +++ b/packages/core/src/hooks/data/useCreateMany.ts @@ -10,7 +10,7 @@ import { SuccessErrorNotification, MetaDataQuery, } from "../../interfaces"; -import { useCacheQueries, useTranslate } from "@hooks"; +import { useCacheQueries, useTranslate, usePublish } from "@hooks"; import { handleNotification } from "@definitions"; import pluralize from "pluralize"; @@ -52,6 +52,7 @@ export const useCreateMany = < const getAllQueries = useCacheQueries(); const translate = useTranslate(); const queryClient = useQueryClient(); + const publish = usePublish(); const mutation = useMutation< CreateManyResponse, @@ -65,7 +66,7 @@ export const useCreateMany = < metaData, }), { - onSuccess: (_, { resource, successNotification }) => { + onSuccess: (response, { resource, successNotification }) => { const resourcePlural = pluralize.plural(resource); handleNotification(successNotification, { @@ -86,6 +87,17 @@ export const useCreateMany = < getAllQueries(resource).forEach((query) => { queryClient.invalidateQueries(query.queryKey); }); + + publish?.({ + channel: `resources/${resource}`, + type: "created", + payload: { + ids: response.data + .filter((item) => item?.id !== undefined) + .map((item) => item.id!.toString()), + }, + date: new Date(), + }); }, onError: (err: TError, { resource, errorNotification }) => { handleNotification(errorNotification, { diff --git a/packages/core/src/hooks/data/useDelete.spec.tsx b/packages/core/src/hooks/data/useDelete.spec.tsx index 04ed9867d28e..a9d4918c036c 100644 --- a/packages/core/src/hooks/data/useDelete.spec.tsx +++ b/packages/core/src/hooks/data/useDelete.spec.tsx @@ -86,4 +86,46 @@ describe("useDelete Hook", () => { expect(isSuccess).toBeTruthy(); }); + + describe("usePublish", () => { + it("publish live event on success", async () => { + const onPublishMock = jest.fn(); + + const { result, waitForNextUpdate, waitFor } = renderHook( + () => useDelete(), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: jest.fn(), + publish: onPublishMock, + }, + }), + }, + ); + + result.current.mutate({ + id: "1", + resource: "posts", + mutationMode: "pessimistic", + }); + await waitForNextUpdate(); + + await waitFor(() => { + return result.current.isSuccess; + }); + + expect(onPublishMock).toBeCalled(); + expect(onPublishMock).toHaveBeenCalledWith({ + channel: "resources/posts", + date: expect.any(Date), + type: "deleted", + payload: { + ids: ["1"], + }, + }); + }); + }); }); diff --git a/packages/core/src/hooks/data/useDelete.ts b/packages/core/src/hooks/data/useDelete.ts index 49fec144f040..7e9940de18d0 100644 --- a/packages/core/src/hooks/data/useDelete.ts +++ b/packages/core/src/hooks/data/useDelete.ts @@ -7,6 +7,7 @@ import { useCacheQueries, useTranslate, useCheckError, + usePublish, } from "@hooks"; import { DataContext } from "@contexts/data"; import { ActionTypes } from "@contexts/notification"; @@ -71,6 +72,7 @@ export const useDelete = < const { notificationDispatch } = useCancelNotification(); const translate = useTranslate(); + const publish = usePublish(); const cacheQueries = useCacheQueries(); @@ -242,6 +244,15 @@ export const useDelete = < ), type: "success", }); + + publish?.({ + channel: `resources/${resource}`, + type: "deleted", + payload: { + ids: id ? [id.toString()] : [], + }, + date: new Date(), + }); }, onSettled: (_data, _error, { id, resource }) => { const allQueries = cacheQueries(resource, id); diff --git a/packages/core/src/hooks/data/useDeleteMany.spec.tsx b/packages/core/src/hooks/data/useDeleteMany.spec.tsx index 0e6b91328f4f..d80058268383 100644 --- a/packages/core/src/hooks/data/useDeleteMany.spec.tsx +++ b/packages/core/src/hooks/data/useDeleteMany.spec.tsx @@ -85,4 +85,45 @@ describe("useDeleteMany Hook", () => { expect(isSuccess).toBeTruthy(); }); + + describe("usePublish", () => { + it("publish live event on success", async () => { + const onPublishMock = jest.fn(); + + const { result, waitForNextUpdate, waitFor } = renderHook( + () => useDeleteMany(), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: jest.fn(), + publish: onPublishMock, + }, + }), + }, + ); + + result.current.mutate({ + resource: "posts", + ids: ["1", "2"], + }); + await waitForNextUpdate(); + + await waitFor(() => { + return result.current.isSuccess; + }); + + expect(onPublishMock).toBeCalled(); + expect(onPublishMock).toHaveBeenCalledWith({ + channel: "resources/posts", + date: expect.any(Date), + type: "deleted", + payload: { + ids: ["1", "2"], + }, + }); + }); + }); }); diff --git a/packages/core/src/hooks/data/useDeleteMany.ts b/packages/core/src/hooks/data/useDeleteMany.ts index 33c6927cb49a..f7ada7ee80e0 100644 --- a/packages/core/src/hooks/data/useDeleteMany.ts +++ b/packages/core/src/hooks/data/useDeleteMany.ts @@ -22,6 +22,7 @@ import { useCancelNotification, useCacheQueries, useCheckError, + usePublish, } from "@hooks"; import { ActionTypes } from "@contexts/notification"; import { handleNotification } from "@definitions"; @@ -71,6 +72,7 @@ export const useDeleteMany = < const { notificationDispatch } = useCancelNotification(); const translate = useTranslate(); const cacheQueries = useCacheQueries(); + const publish = usePublish(); const queryClient = useQueryClient(); @@ -215,6 +217,13 @@ export const useDeleteMany = < ), type: "success", }); + + publish?.({ + channel: `resources/${resource}`, + type: "deleted", + payload: { ids: ids.map(String) }, + date: new Date(), + }); }, onError: (err, { ids, resource, errorNotification }, context) => { if (context) { diff --git a/packages/core/src/hooks/data/useList.spec.tsx b/packages/core/src/hooks/data/useList.spec.tsx index 1825b8c52d0c..ab60ff808a97 100644 --- a/packages/core/src/hooks/data/useList.spec.tsx +++ b/packages/core/src/hooks/data/useList.spec.tsx @@ -1,8 +1,17 @@ import { renderHook } from "@testing-library/react-hooks"; -import { MockJSONServer, TestWrapper } from "@test"; +import { MockJSONServer, MockLiveProvider, TestWrapper } from "@test"; import { useList } from "./useList"; +import { IRefineContextProvider } from "src/interfaces"; + +const mockRefineProvider: IRefineContextProvider = { + hasDashboard: false, + mutationMode: "pessimistic", + warnWhenUnsavedChanges: false, + syncWithLocation: false, + undoableTimeout: 500, +}; describe("useList Hook", () => { it("with rest json server", async () => { @@ -25,4 +34,119 @@ describe("useList Hook", () => { expect(data?.data).toHaveLength(2); expect(data?.total).toEqual(2); }); + + describe("useResourceSubscription", () => { + it("useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + renderHook( + () => + useList({ + resource: "posts", + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + expect(onSubscribeMock).toHaveBeenCalledWith({ + channel: "resources/posts", + callback: expect.any(Function), + params: undefined, + types: ["*"], + }); + }); + + it("liveMode = Off useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + renderHook( + () => + useList({ + resource: "posts", + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }, + ); + + expect(onSubscribeMock).not.toBeCalled(); + }); + + it("liveMode = Off and liveMode hook param auto", async () => { + const onSubscribeMock = jest.fn(); + + renderHook(() => useList({ resource: "posts", liveMode: "auto" }), { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }); + + expect(onSubscribeMock).toBeCalled(); + }); + + it("unsubscribe call on unmount", async () => { + const onSubscribeMock = jest.fn(() => true); + const onUnsubscribeMock = jest.fn(); + + const { unmount } = renderHook( + () => + useList({ + resource: "posts", + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: onUnsubscribeMock, + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + + unmount(); + expect(onUnsubscribeMock).toBeCalledWith(true); + expect(onUnsubscribeMock).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/core/src/hooks/data/useList.ts b/packages/core/src/hooks/data/useList.ts index 1bf424d55d39..76d246bafc2f 100644 --- a/packages/core/src/hooks/data/useList.ts +++ b/packages/core/src/hooks/data/useList.ts @@ -12,8 +12,9 @@ import { HttpError, CrudSorting, MetaDataQuery, + LiveModeProps, } from "../../interfaces"; -import { useCheckError, useTranslate } from "@hooks"; +import { useCheckError, useResourceSubscription, useTranslate } from "@hooks"; import { handleNotification } from "@definitions"; interface UseListConfig { @@ -29,7 +30,7 @@ export type UseListProps = { successNotification?: ArgsProps | false; errorNotification?: ArgsProps | false; metaData?: MetaDataQuery; -}; +} & LiveModeProps; /** * `useList` is a modified version of `react-query`'s {@link https://react-query.tanstack.com/guides/queries `useQuery`} used for retrieving items from a `resource` with pagination, sort, and filter configurations. @@ -52,6 +53,9 @@ export const useList = < successNotification, errorNotification, metaData, + liveMode, + onLiveEvent, + liveParams, }: UseListProps): QueryObserverResult< GetListResponse, TError @@ -60,6 +64,19 @@ export const useList = < const translate = useTranslate(); const { mutate: checkError } = useCheckError(); + const isEnabled = + queryOptions?.enabled === undefined || queryOptions?.enabled === true; + + useResourceSubscription({ + resource, + types: ["*"], + params: liveParams, + channel: `resources/${resource}`, + enabled: isEnabled, + liveMode, + onLiveEvent, + }); + const queryResponse = useQuery, TError>( [`resource/list/${resource}`, { ...config }], () => getList({ resource, ...config, metaData }), diff --git a/packages/core/src/hooks/data/useMany.spec.tsx b/packages/core/src/hooks/data/useMany.spec.tsx index a13831f48d26..c41b81b687bf 100644 --- a/packages/core/src/hooks/data/useMany.spec.tsx +++ b/packages/core/src/hooks/data/useMany.spec.tsx @@ -3,6 +3,15 @@ import { renderHook } from "@testing-library/react-hooks"; import { MockJSONServer, TestWrapper } from "@test"; import { useMany } from "./useMany"; +import { IRefineContextProvider } from "src/interfaces"; + +const mockRefineProvider: IRefineContextProvider = { + hasDashboard: false, + mutationMode: "pessimistic", + warnWhenUnsavedChanges: false, + syncWithLocation: false, + undoableTimeout: 500, +}; describe("useMany Hook", () => { it("with rest json server", async () => { @@ -25,4 +34,112 @@ describe("useMany Hook", () => { expect(status).toBe("success"); expect(data?.data.length).toBe(2); }); + + describe("useResourceSubscription", () => { + it("useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + renderHook(() => useMany({ resource: "posts", ids: ["1", "2"] }), { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }); + + expect(onSubscribeMock).toBeCalled(); + expect(onSubscribeMock).toHaveBeenCalledWith({ + channel: "resources/posts", + callback: expect.any(Function), + params: { ids: ["1", "2"] }, + types: ["*"], + }); + }); + + it("liveMode = Off useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + renderHook(() => useMany({ resource: "posts", ids: ["1", "2"] }), { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }); + + expect(onSubscribeMock).not.toBeCalled(); + }); + + it("liveMode = Off and liveMode hook param auto", async () => { + const onSubscribeMock = jest.fn(); + + renderHook( + () => + useMany({ + resource: "posts", + ids: ["1", "2"], + liveMode: "auto", + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + }); + + it("unsubscribe call on unmount", async () => { + const onSubscribeMock = jest.fn(() => true); + const onUnsubscribeMock = jest.fn(); + + const { unmount } = renderHook( + () => useMany({ resource: "posts", ids: ["1", "2"] }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: onUnsubscribeMock, + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + + unmount(); + expect(onUnsubscribeMock).toBeCalledWith(true); + expect(onUnsubscribeMock).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/core/src/hooks/data/useMany.ts b/packages/core/src/hooks/data/useMany.ts index d4e06c34a5fe..0fb0b93b0d2e 100644 --- a/packages/core/src/hooks/data/useMany.ts +++ b/packages/core/src/hooks/data/useMany.ts @@ -9,8 +9,9 @@ import { GetManyResponse, HttpError, MetaDataQuery, + LiveModeProps, } from "../../interfaces"; -import { useTranslate, useCheckError } from "@hooks"; +import { useTranslate, useCheckError, useResourceSubscription } from "@hooks"; import { handleNotification } from "@definitions"; export type UseManyProps = { @@ -20,7 +21,7 @@ export type UseManyProps = { successNotification?: ArgsProps | false; errorNotification?: ArgsProps | false; metaData?: MetaDataQuery; -}; +} & LiveModeProps; /** * `useMany` is a modified version of `react-query`'s {@link https://react-query.tanstack.com/guides/queries `useQuery`} used for retrieving multiple items from a `resource`. @@ -43,6 +44,9 @@ export const useMany = < successNotification, errorNotification, metaData, + liveMode, + onLiveEvent, + liveParams, }: UseManyProps): QueryObserverResult< GetManyResponse > => { @@ -50,6 +54,19 @@ export const useMany = < const translate = useTranslate(); const { mutate: checkError } = useCheckError(); + const isEnabled = + queryOptions?.enabled === undefined || queryOptions?.enabled === true; + + useResourceSubscription({ + resource, + types: ["*"], + params: { ids: ids ? ids?.map(String) : [], ...liveParams }, + channel: `resources/${resource}`, + enabled: isEnabled, + liveMode, + onLiveEvent, + }); + const queryResponse = useQuery, TError>( [`resource/getMany/${resource}`, ids], () => getMany({ resource, ids, metaData }), diff --git a/packages/core/src/hooks/data/useOne.spec.tsx b/packages/core/src/hooks/data/useOne.spec.tsx index f4aea345b1fc..baf1cc6f1493 100644 --- a/packages/core/src/hooks/data/useOne.spec.tsx +++ b/packages/core/src/hooks/data/useOne.spec.tsx @@ -3,6 +3,15 @@ import { renderHook } from "@testing-library/react-hooks"; import { MockJSONServer, TestWrapper } from "@test"; import { useOne } from "./useOne"; +import { IRefineContextProvider } from "src/interfaces"; + +const mockRefineProvider: IRefineContextProvider = { + hasDashboard: false, + mutationMode: "pessimistic", + warnWhenUnsavedChanges: false, + syncWithLocation: false, + undoableTimeout: 500, +}; describe("useOne Hook", () => { it("with rest json server", async () => { @@ -25,4 +34,107 @@ describe("useOne Hook", () => { expect(status).toBe("success"); expect(data?.data.slug).toBe("ut-ad-et"); }); + + describe("useResourceSubscription", () => { + it("useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + renderHook(() => useOne({ resource: "posts", id: "1" }), { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }); + + expect(onSubscribeMock).toBeCalled(); + expect(onSubscribeMock).toHaveBeenCalledWith({ + channel: "resources/posts", + callback: expect.any(Function), + params: { ids: ["1"] }, + types: ["*"], + }); + }); + + it("liveMode = Off useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + renderHook(() => useOne({ resource: "posts", id: "1" }), { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }); + + expect(onSubscribeMock).not.toBeCalled(); + }); + + it("liveMode = Off and liveMode hook param auto", async () => { + const onSubscribeMock = jest.fn(); + + renderHook( + () => useOne({ resource: "posts", id: "1", liveMode: "auto" }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + }); + + it("unsubscribe call on unmount", async () => { + const onSubscribeMock = jest.fn(() => true); + const onUnsubscribeMock = jest.fn(); + + const { unmount } = renderHook( + () => useOne({ resource: "posts", id: "1" }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: onUnsubscribeMock, + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + + unmount(); + expect(onUnsubscribeMock).toBeCalledWith(true); + expect(onUnsubscribeMock).toBeCalledTimes(1); + }); + }); }); diff --git a/packages/core/src/hooks/data/useOne.ts b/packages/core/src/hooks/data/useOne.ts index 2b392c2b1525..fef3c38c86b4 100644 --- a/packages/core/src/hooks/data/useOne.ts +++ b/packages/core/src/hooks/data/useOne.ts @@ -1,6 +1,5 @@ import { useContext } from "react"; import { QueryObserverResult, useQuery, UseQueryOptions } from "react-query"; - import { DataContext } from "@contexts/data"; import { GetOneResponse, @@ -8,8 +7,9 @@ import { HttpError, BaseRecord, MetaDataQuery, + LiveModeProps, } from "../../interfaces"; -import { useCheckError, useTranslate } from "@hooks"; +import { useCheckError, useTranslate, useResourceSubscription } from "@hooks"; import { ArgsProps } from "antd/lib/notification"; import { handleNotification } from "@definitions"; @@ -20,7 +20,7 @@ export type UseOneProps = { successNotification?: ArgsProps | false; errorNotification?: ArgsProps | false; metaData?: MetaDataQuery; -}; +} & LiveModeProps; /** * `useOne` is a modified version of `react-query`'s {@link https://react-query.tanstack.com/guides/queries `useQuery`} used for retrieving single items from a `resource`. @@ -43,11 +43,24 @@ export const useOne = < successNotification, errorNotification, metaData, + liveMode, + onLiveEvent, + liveParams, }: UseOneProps): QueryObserverResult> => { const { getOne } = useContext(DataContext); const translate = useTranslate(); const { mutate: checkError } = useCheckError(); + useResourceSubscription({ + resource, + types: ["*"], + channel: `resources/${resource}`, + params: { ids: id ? [id.toString()] : [], ...liveParams }, + enabled: queryOptions?.enabled, + liveMode, + onLiveEvent, + }); + const queryResponse = useQuery, TError>( [`resource/getOne/${resource}`, { id }], () => getOne({ resource, id, metaData }), diff --git a/packages/core/src/hooks/data/useUpdate.spec.tsx b/packages/core/src/hooks/data/useUpdate.spec.tsx index 0125bc50553d..cec6074f53f5 100644 --- a/packages/core/src/hooks/data/useUpdate.spec.tsx +++ b/packages/core/src/hooks/data/useUpdate.spec.tsx @@ -89,4 +89,48 @@ describe("useUpdate Hook", () => { expect(isSuccess).toBeTruthy(); }); + + describe("usePublish", () => { + it("publish live event on success", async () => { + const onPublishMock = jest.fn(); + + const { result, waitForNextUpdate, waitFor } = renderHook( + () => useUpdate(), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: jest.fn(), + publish: onPublishMock, + }, + }), + }, + ); + + result.current.mutate({ + resource: "posts", + mutationMode: "undoable", + undoableTimeout: 0, + id: "1", + values: { id: "1", title: "undoable test" }, + }); + await waitForNextUpdate(); + + await waitFor(() => { + return result.current.isSuccess; + }); + + expect(onPublishMock).toBeCalled(); + expect(onPublishMock).toHaveBeenCalledWith({ + channel: "resources/posts", + date: expect.any(Date), + type: "updated", + payload: { + ids: ["1"], + }, + }); + }); + }); }); diff --git a/packages/core/src/hooks/data/useUpdate.ts b/packages/core/src/hooks/data/useUpdate.ts index 768cbd1ea416..7e82e7e462a1 100644 --- a/packages/core/src/hooks/data/useUpdate.ts +++ b/packages/core/src/hooks/data/useUpdate.ts @@ -22,6 +22,7 @@ import { useCacheQueries, useTranslate, useCheckError, + usePublish, } from "@hooks"; import { handleNotification } from "@definitions/helpers"; @@ -71,7 +72,7 @@ export const useUpdate = < } = useMutationMode(); const translate = useTranslate(); const { mutate: checkError } = useCheckError(); - + const publish = usePublish(); const { notificationDispatch } = useCancelNotification(); const getAllQueries = useCacheQueries(); @@ -249,7 +250,7 @@ export const useUpdate = < payload: { id, resource }, }); }, - onSuccess: (_data, { id, resource, successNotification }) => { + onSuccess: (data, { id, resource, successNotification }) => { const resourceSingular = pluralize.singular(resource); handleNotification(successNotification, { @@ -267,6 +268,17 @@ export const useUpdate = < ), type: "success", }); + + publish?.({ + channel: `resources/${resource}`, + type: "updated", + payload: { + ids: data.data?.id + ? [data.data.id.toString()] + : undefined, + }, + date: new Date(), + }); }, }, ); diff --git a/packages/core/src/hooks/data/useUpdateMany.spec.tsx b/packages/core/src/hooks/data/useUpdateMany.spec.tsx index e304985cc93a..727f90b43993 100644 --- a/packages/core/src/hooks/data/useUpdateMany.spec.tsx +++ b/packages/core/src/hooks/data/useUpdateMany.spec.tsx @@ -116,4 +116,48 @@ describe("useUpdateMany Hook", () => { expect(isSuccess).toBeTruthy(); }); + + describe("usePublish", () => { + it("publish live event on success", async () => { + const onPublishMock = jest.fn(); + + const { result, waitForNextUpdate, waitFor } = renderHook( + () => useUpdateMany(), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: jest.fn(), + publish: onPublishMock, + }, + }), + }, + ); + + result.current.mutate({ + resource: "posts", + mutationMode: "undoable", + undoableTimeout: 0, + ids: ["1", "2"], + values: { id: "1", title: "undoable test" }, + }); + await waitForNextUpdate(); + + await waitFor(() => { + return result.current.isSuccess; + }); + + expect(onPublishMock).toBeCalled(); + expect(onPublishMock).toHaveBeenCalledWith({ + channel: "resources/posts", + date: expect.any(Date), + type: "updated", + payload: { + ids: ["1", "2"], + }, + }); + }); + }); }); diff --git a/packages/core/src/hooks/data/useUpdateMany.ts b/packages/core/src/hooks/data/useUpdateMany.ts index 638d11fcf027..cc898a01f065 100644 --- a/packages/core/src/hooks/data/useUpdateMany.ts +++ b/packages/core/src/hooks/data/useUpdateMany.ts @@ -9,6 +9,7 @@ import { useCheckError, useMutationMode, useTranslate, + usePublish, } from "@hooks"; import { ActionTypes } from "@contexts/notification"; import { @@ -73,6 +74,7 @@ export const useUpdateMany = < const { mutate: checkError } = useCheckError(); const { notificationDispatch } = useCancelNotification(); + const publish = usePublish(); const getAllQueries = useCacheQueries(); @@ -266,6 +268,15 @@ export const useUpdateMany = < ), type: "success", }); + + publish?.({ + channel: `resources/${resource}`, + type: "updated", + payload: { + ids: ids.map(String), + }, + date: new Date(), + }); }, }, ); diff --git a/packages/core/src/hooks/fields/useCheckboxGroup/index.ts b/packages/core/src/hooks/fields/useCheckboxGroup/index.ts index 37fd5864e959..f2cef05292f7 100644 --- a/packages/core/src/hooks/fields/useCheckboxGroup/index.ts +++ b/packages/core/src/hooks/fields/useCheckboxGroup/index.ts @@ -12,6 +12,7 @@ import { SuccessErrorNotification, HttpError, MetaDataQuery, + LiveModeProps, } from "../../../interfaces"; export type useCheckboxGroupProps = { @@ -22,7 +23,8 @@ export type useCheckboxGroupProps = { filters?: CrudFilters; queryOptions?: UseQueryOptions, TError>; metaData?: MetaDataQuery; -} & SuccessErrorNotification; +} & SuccessErrorNotification & + LiveModeProps; export type UseCheckboxGroupReturnType = { @@ -50,6 +52,9 @@ export const useCheckboxGroup = < successNotification, errorNotification, queryOptions, + liveMode, + onLiveEvent, + liveParams, metaData, }: useCheckboxGroupProps): UseCheckboxGroupReturnType => { const [options, setOptions] = React.useState([]); @@ -78,8 +83,12 @@ export const useCheckboxGroup = < }, successNotification, errorNotification, + liveMode, + onLiveEvent, + liveParams, metaData, }); + return { checkboxGroupProps: { options, diff --git a/packages/core/src/hooks/fields/useRadioGroup/index.ts b/packages/core/src/hooks/fields/useRadioGroup/index.ts index dc8e4754d0af..9d93e8963131 100644 --- a/packages/core/src/hooks/fields/useRadioGroup/index.ts +++ b/packages/core/src/hooks/fields/useRadioGroup/index.ts @@ -12,6 +12,7 @@ import { SuccessErrorNotification, HttpError, MetaDataQuery, + LiveModeProps, } from "../../../interfaces"; export type useRadioGroupProps = RadioGroupProps & { @@ -22,7 +23,8 @@ export type useRadioGroupProps = RadioGroupProps & { filters?: CrudFilters; queryOptions?: UseQueryOptions, TError>; metaData?: MetaDataQuery; -} & SuccessErrorNotification; +} & SuccessErrorNotification & + LiveModeProps; export type UseRadioGroupReturnType = { radioGroupProps: RadioGroupProps; @@ -50,6 +52,9 @@ export const useRadioGroup = < successNotification, errorNotification, queryOptions, + liveMode, + onLiveEvent, + liveParams, metaData, }: useRadioGroupProps): UseRadioGroupReturnType => { const [options, setOptions] = React.useState([]); @@ -78,6 +83,9 @@ export const useRadioGroup = < }, successNotification, errorNotification, + liveMode, + onLiveEvent, + liveParams, metaData, }); diff --git a/packages/core/src/hooks/fields/useSelect/index.ts b/packages/core/src/hooks/fields/useSelect/index.ts index b1c15e5314f7..311d977f26d1 100644 --- a/packages/core/src/hooks/fields/useSelect/index.ts +++ b/packages/core/src/hooks/fields/useSelect/index.ts @@ -15,6 +15,7 @@ import { SuccessErrorNotification, HttpError, MetaDataQuery, + LiveModeProps, } from "../../../interfaces"; export type UseSelectProps = { @@ -28,7 +29,8 @@ export type UseSelectProps = { queryOptions?: UseQueryOptions, TError>; defaultValueQueryOptions?: UseQueryOptions, TError>; metaData?: MetaDataQuery; -} & SuccessErrorNotification; +} & SuccessErrorNotification & + LiveModeProps; export type UseSelectReturnType = { selectProps: SelectProps<{ value: string; label: string }>; @@ -67,6 +69,9 @@ export const useSelect = < errorNotification, defaultValueQueryOptions, queryOptions, + liveMode, + onLiveEvent, + liveParams, metaData, } = props; @@ -95,6 +100,7 @@ export const useSelect = < }, }, metaData, + liveMode: "off", }); const defaultQueryOnSuccess = (data: GetListResponse) => { @@ -113,7 +119,6 @@ export const useSelect = < filters: filters.concat(search), }, queryOptions: { - enabled: false, ...queryOptions, onSuccess: (data) => { defaultQueryOnSuccess(data); @@ -123,6 +128,9 @@ export const useSelect = < successNotification, errorNotification, metaData, + liveMode, + liveParams, + onLiveEvent, }); const { refetch: refetchList } = queryResult; diff --git a/packages/core/src/hooks/form/useDrawerForm/useDrawerForm.ts b/packages/core/src/hooks/form/useDrawerForm/useDrawerForm.ts index 7371a6fb2864..05a8ce11dd0b 100644 --- a/packages/core/src/hooks/form/useDrawerForm/useDrawerForm.ts +++ b/packages/core/src/hooks/form/useDrawerForm/useDrawerForm.ts @@ -7,7 +7,7 @@ import { useTranslate, useWarnAboutChange, } from "@hooks"; -import { BaseRecord, HttpError } from "../../../interfaces"; +import { BaseRecord, HttpError, LiveModeProps } from "../../../interfaces"; import { DeleteButtonProps } from "../../../components/buttons/delete"; import { useFormProps, UseFormReturnType } from "../useForm"; @@ -19,7 +19,9 @@ export type UseDrawerFormProps< TData extends BaseRecord = BaseRecord, TError extends HttpError = HttpError, TVariables = {}, -> = useFormProps & UseDrawerFormConfig; +> = useFormProps & + UseDrawerFormConfig & + LiveModeProps; export type UseDrawerFormReturnType< TData extends BaseRecord = BaseRecord, diff --git a/packages/core/src/hooks/form/useEditForm/useEditForm.ts b/packages/core/src/hooks/form/useEditForm/useEditForm.ts index f0aa7f7253a5..aa9df56aa52c 100644 --- a/packages/core/src/hooks/form/useEditForm/useEditForm.ts +++ b/packages/core/src/hooks/form/useEditForm/useEditForm.ts @@ -24,6 +24,7 @@ import { HttpError, SuccessErrorNotification, MetaDataQuery, + LiveModeProps, } from "../../../interfaces"; import { ActionParams } from "../useForm"; @@ -72,8 +73,9 @@ export type useEditFormProps< undoableTimeout?: number; resource: IResourceItem; metaData?: MetaDataQuery; -} & ActionParams & - SuccessErrorNotification; +} & SuccessErrorNotification & + ActionParams & + LiveModeProps; /** * A hook that the `useForm` uses @@ -94,6 +96,9 @@ export const useEditForm = < resource, successNotification, errorNotification, + liveMode, + onLiveEvent, + liveParams, metaData, action: actionFromParams, }: useEditFormProps): useEditForm< @@ -136,6 +141,9 @@ export const useEditForm = < queryOptions: { enabled: isEdit, }, + liveMode, + onLiveEvent, + liveParams, metaData, }); diff --git a/packages/core/src/hooks/form/useForm.ts b/packages/core/src/hooks/form/useForm.ts index 9a3ef80ce720..7e2192c35713 100644 --- a/packages/core/src/hooks/form/useForm.ts +++ b/packages/core/src/hooks/form/useForm.ts @@ -13,6 +13,7 @@ import { BaseRecord, GetOneResponse, HttpError, + LiveModeProps, ResourceRouterParams, } from "../../interfaces"; import { UseUpdateReturnType } from "../data/useUpdate"; @@ -42,7 +43,8 @@ export type useFormProps< TVariables = {}, > = ActionParams & { resource?: string; -} & ResourcelessActionFormProps; +} & ResourcelessActionFormProps & + LiveModeProps; export type UseFormReturnType< TData extends BaseRecord = BaseRecord, diff --git a/packages/core/src/hooks/form/useModalForm/useModalForm.ts b/packages/core/src/hooks/form/useModalForm/useModalForm.ts index 793670650544..4b19739534a0 100644 --- a/packages/core/src/hooks/form/useModalForm/useModalForm.ts +++ b/packages/core/src/hooks/form/useModalForm/useModalForm.ts @@ -15,6 +15,7 @@ import { import { BaseRecord, HttpError, + LiveModeProps, ResourceRouterParams, } from "../../../interfaces"; import { userFriendlyResourceName } from "@definitions"; @@ -41,7 +42,8 @@ export type useModalFormProps< TVariables = {}, > = useFormProps & UseModalFormConfigSF & - useModalFormConfig; + useModalFormConfig & + LiveModeProps; /** * `useModalForm` hook allows you to manage a form within a modal. It returns Ant Design {@link https://ant.design/components/form/ Form} and {@link https://ant.design/components/modal/ Modal} components props. * diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index a36c62cc3f31..cf2f69ec6c29 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -1,5 +1,6 @@ export * from "./auth"; export * from "./data"; +export * from "./live"; export * from "./resource"; export * from "./notification"; export * from "./useFileUploadState"; diff --git a/packages/core/src/hooks/list/useSimpleList/useSimpleList.ts b/packages/core/src/hooks/list/useSimpleList/useSimpleList.ts index 8f720cf5a92a..984b0dadd380 100644 --- a/packages/core/src/hooks/list/useSimpleList/useSimpleList.ts +++ b/packages/core/src/hooks/list/useSimpleList/useSimpleList.ts @@ -21,6 +21,7 @@ import { SuccessErrorNotification, HttpError, MetaDataQuery, + LiveModeProps, } from "../../../interfaces"; import { parseTableParams, @@ -41,7 +42,8 @@ export type useSimpleListProps = ) => CrudFilters | Promise; queryOptions?: UseQueryOptions, TError>; metaData?: MetaDataQuery; - } & SuccessErrorNotification; + } & SuccessErrorNotification & + LiveModeProps; export type useSimpleListReturnType< TData extends BaseRecord = BaseRecord, @@ -79,6 +81,9 @@ export const useSimpleList = < syncWithLocation: syncWithLocationProp, successNotification, errorNotification, + liveMode, + onLiveEvent, + liveParams, metaData, ...listProps }: useSimpleListProps< @@ -165,6 +170,9 @@ export const useSimpleList = < queryOptions, successNotification, errorNotification, + liveMode, + onLiveEvent, + liveParams, metaData, }); const { data, isFetching } = queryResult; diff --git a/packages/core/src/hooks/live/index.ts b/packages/core/src/hooks/live/index.ts new file mode 100644 index 000000000000..5a2b5d2588d4 --- /dev/null +++ b/packages/core/src/hooks/live/index.ts @@ -0,0 +1,4 @@ +export * from "./useResourceSubscription"; +export * from "./useLiveMode"; +export * from "./useSubscription"; +export * from "./usePublish"; diff --git a/packages/core/src/hooks/live/useLiveMode/index.spec.ts b/packages/core/src/hooks/live/useLiveMode/index.spec.ts new file mode 100644 index 000000000000..2fd1e8912267 --- /dev/null +++ b/packages/core/src/hooks/live/useLiveMode/index.spec.ts @@ -0,0 +1,42 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { TestWrapper } from "@test"; + +import { useLiveMode } from "./"; +import { IRefineContextProvider } from "src/interfaces"; + +const mockRefineProvider: IRefineContextProvider = { + hasDashboard: false, + mutationMode: "pessimistic", + warnWhenUnsavedChanges: false, + syncWithLocation: false, + undoableTimeout: 500, +}; + +describe("useLiveMode Hook", () => { + it("context: auto, params: off -> returns off", async () => { + const { result } = renderHook(() => useLiveMode("off"), { + wrapper: TestWrapper({ + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }); + + expect(result.current).toBe("off"); + }); + + it("returns context value", async () => { + const { result } = renderHook(() => useLiveMode(undefined), { + wrapper: TestWrapper({ + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }); + + expect(result.current).toBe("auto"); + }); +}); diff --git a/packages/core/src/hooks/live/useLiveMode/index.ts b/packages/core/src/hooks/live/useLiveMode/index.ts new file mode 100644 index 000000000000..f9b72fe0ee0c --- /dev/null +++ b/packages/core/src/hooks/live/useLiveMode/index.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { LiveModeProps, IRefineContext } from "../../../interfaces"; +import { RefineContext } from "@contexts/refine"; + +export const useLiveMode = ( + liveMode: LiveModeProps["liveMode"], +): LiveModeProps["liveMode"] => { + const { liveMode: liveModeFromContext } = + useContext(RefineContext); + + return liveMode ?? liveModeFromContext; +}; diff --git a/packages/core/src/hooks/live/usePublish/index.spec.ts b/packages/core/src/hooks/live/usePublish/index.spec.ts new file mode 100644 index 000000000000..99c10cd801b7 --- /dev/null +++ b/packages/core/src/hooks/live/usePublish/index.spec.ts @@ -0,0 +1,33 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { TestWrapper } from "@test"; + +import { usePublish } from "./"; + +const publishMock = jest.fn(); +describe("usePublish Hook", () => { + it("publish event", async () => { + const { result } = renderHook(() => usePublish(), { + wrapper: TestWrapper({ + liveProvider: { + subscribe: () => jest.fn(), + unsubscribe: () => jest.fn(), + publish: publishMock, + }, + }), + }); + + const publish = result.current; + + const publishPayload = { + channel: "channel", + date: new Date(), + payload: { ids: ["1"] }, + type: "created", + }; + publish?.(publishPayload); + + expect(publishMock).toBeCalledWith(publishPayload); + expect(publishMock).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/hooks/live/usePublish/index.ts b/packages/core/src/hooks/live/usePublish/index.ts new file mode 100644 index 000000000000..b9ca0853d5fd --- /dev/null +++ b/packages/core/src/hooks/live/usePublish/index.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; + +import { LiveContext } from "@contexts/live"; +import { ILiveContext } from "../../../interfaces"; + +export const usePublish: () => NonNullable["publish"] = () => { + const liveContext = useContext(LiveContext); + + return liveContext?.publish; +}; diff --git a/packages/core/src/hooks/live/useResourceSubscription/index.spec.ts b/packages/core/src/hooks/live/useResourceSubscription/index.spec.ts new file mode 100644 index 000000000000..061afb1af4c5 --- /dev/null +++ b/packages/core/src/hooks/live/useResourceSubscription/index.spec.ts @@ -0,0 +1,183 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { TestWrapper } from "@test"; + +import { useResourceSubscription } from "./"; +import { IRefineContextProvider } from "src/interfaces"; + +const mockRefineProvider: IRefineContextProvider = { + hasDashboard: false, + mutationMode: "pessimistic", + warnWhenUnsavedChanges: false, + syncWithLocation: false, + undoableTimeout: 500, +}; + +const onLiveEventMock = jest.fn(); +describe("useResourceSubscription Hook", () => { + it("useResourceSubscription enabled and all types", async () => { + const onSubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + renderHook( + () => + useResourceSubscription({ + onLiveEvent: onLiveEventMock, + channel: subscriptionParams.channel, + resource: "posts", + types: ["*"], + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: () => jest.fn(), + publish: () => jest.fn(), + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + }); + + it("useResourceSubscription liveMode off", async () => { + const onSubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + renderHook( + () => + useResourceSubscription({ + onLiveEvent: onLiveEventMock, + channel: subscriptionParams.channel, + resource: "posts", + types: ["*"], + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: () => jest.fn(), + publish: () => jest.fn(), + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }, + ); + + expect(onSubscribeMock).not.toBeCalled(); + }); + + it("useResourceSubscription liveMode on context off, params auto", async () => { + const onSubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + renderHook( + () => + useResourceSubscription({ + onLiveEvent: onLiveEventMock, + channel: subscriptionParams.channel, + resource: "posts", + types: ["*"], + liveMode: "auto", + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: () => jest.fn(), + publish: () => jest.fn(), + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + }); + + it("useResourceSubscription subscribe undefined", async () => { + const onSubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + renderHook( + () => + useResourceSubscription({ + channel: subscriptionParams.channel, + onLiveEvent: onLiveEventMock, + resource: "posts", + types: ["*"], + }), + { + wrapper: TestWrapper({ + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + expect(onSubscribeMock).not.toBeCalled(); + }); + + it("useResourceSubscription calls unsubscribe on unmount", async () => { + const onSubscribeMock = jest.fn(() => true); + const onUnsubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + const { unmount } = renderHook( + () => + useResourceSubscription({ + onLiveEvent: onLiveEventMock, + channel: subscriptionParams.channel, + resource: "posts", + types: ["*"], + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: onUnsubscribeMock, + publish: () => jest.fn(), + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + + unmount(); + expect(onUnsubscribeMock).toBeCalledWith(true); + expect(onUnsubscribeMock).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/hooks/live/useResourceSubscription/index.ts b/packages/core/src/hooks/live/useResourceSubscription/index.ts new file mode 100644 index 000000000000..d3b52c2a1cec --- /dev/null +++ b/packages/core/src/hooks/live/useResourceSubscription/index.ts @@ -0,0 +1,74 @@ +import { useContext, useEffect } from "react"; +import { useQueryClient } from "react-query"; +import { useCacheQueries } from "@hooks"; +import { + ILiveContext, + IRefineContext, + LiveEvent, + LiveModeProps, +} from "../../../interfaces"; +import { LiveContext } from "@contexts/live"; +import { RefineContext } from "@contexts/refine"; + +export type UseResourceSubscriptionProps = { + channel: string; + params?: { + ids?: string[]; + [key: string]: any; + }; + types: LiveEvent["type"][]; + resource: string; + enabled?: boolean; +} & LiveModeProps; + +export type PublishType = { + (event: LiveEvent): void; +}; + +export const useResourceSubscription = ({ + resource, + params, + channel, + types, + enabled = true, + liveMode: liveModeFromProp, + onLiveEvent, +}: UseResourceSubscriptionProps): void => { + const queryClient = useQueryClient(); + const getAllQueries = useCacheQueries(); + const liveDataContext = useContext(LiveContext); + const { + liveMode: liveModeFromContext, + onLiveEvent: onLiveEventContextCallback, + } = useContext(RefineContext); + + const liveMode = liveModeFromProp ?? liveModeFromContext; + + useEffect(() => { + let subscription: any; + + if (liveMode && liveMode !== "off" && enabled) { + subscription = liveDataContext?.subscribe({ + channel, + params, + types, + callback: (event) => { + if (liveMode === "auto") { + getAllQueries(resource).forEach((query) => { + queryClient.invalidateQueries(query.queryKey); + }); + } + + onLiveEvent?.(event); + onLiveEventContextCallback?.(event); + }, + }); + } + + return () => { + if (subscription) { + liveDataContext?.unsubscribe(subscription); + } + }; + }, [enabled]); +}; diff --git a/packages/core/src/hooks/live/useSubscription/index.spec.ts b/packages/core/src/hooks/live/useSubscription/index.spec.ts new file mode 100644 index 000000000000..00155fbed0e5 --- /dev/null +++ b/packages/core/src/hooks/live/useSubscription/index.spec.ts @@ -0,0 +1,137 @@ +import { renderHook } from "@testing-library/react-hooks"; + +import { TestWrapper } from "@test"; + +import { useSubscription } from "./"; + +const onLiveEventMock = jest.fn(); +describe("useSubscribe Hook", () => { + it("useSubscribe enabled and all types", async () => { + const onSubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + renderHook( + () => + useSubscription({ + channel: "channel", + onLiveEvent: onLiveEventMock, + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: () => jest.fn(), + publish: () => jest.fn(), + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + expect(onSubscribeMock).toHaveBeenCalledWith({ + channel: subscriptionParams.channel, + callback: subscriptionParams.onLiveEvent, + params: undefined, + types: ["*"], + }); + }); + + it("useSubscribe enabled false", async () => { + const onSubscribeMock = jest.fn(); + + renderHook( + () => + useSubscription({ + channel: "channel", + onLiveEvent: onLiveEventMock, + enabled: false, + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: () => jest.fn(), + publish: () => jest.fn(), + }, + }), + }, + ); + + expect(onSubscribeMock).not.toBeCalled(); + }); + + it("useSubscribe spesific type", async () => { + const onSubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + renderHook( + () => + useSubscription({ + channel: "channel", + onLiveEvent: onLiveEventMock, + types: ["test", "test2"], + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: () => jest.fn(), + publish: () => jest.fn(), + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + expect(onSubscribeMock).toHaveBeenCalledWith({ + channel: subscriptionParams.channel, + callback: subscriptionParams.onLiveEvent, + params: undefined, + types: ["test", "test2"], + }); + }); + + it("useSubscribe calls unsubscribe on unmount", async () => { + const onSubscribeMock = jest.fn(() => true); + const onUnsubscribeMock = jest.fn(); + + const subscriptionParams = { + channel: "channel", + onLiveEvent: onLiveEventMock, + }; + const { unmount } = renderHook( + () => + useSubscription({ + channel: "channel", + onLiveEvent: onLiveEventMock, + }), + { + wrapper: TestWrapper({ + liveProvider: { + subscribe: onSubscribeMock, + unsubscribe: onUnsubscribeMock, + publish: () => jest.fn(), + }, + }), + }, + ); + + expect(onSubscribeMock).toBeCalled(); + expect(onSubscribeMock).toHaveBeenCalledWith({ + channel: subscriptionParams.channel, + callback: subscriptionParams.onLiveEvent, + params: undefined, + types: ["*"], + }); + + unmount(); + expect(onUnsubscribeMock).toBeCalledWith(true); + expect(onUnsubscribeMock).toBeCalledTimes(1); + }); +}); diff --git a/packages/core/src/hooks/live/useSubscription/index.ts b/packages/core/src/hooks/live/useSubscription/index.ts new file mode 100644 index 000000000000..49240cdbb1c7 --- /dev/null +++ b/packages/core/src/hooks/live/useSubscription/index.ts @@ -0,0 +1,43 @@ +import { useContext, useEffect } from "react"; + +import { LiveContext } from "@contexts/live"; +import { ILiveContext, LiveEvent } from "../../../interfaces"; + +export type UseSubscriptionProps = { + channel: string; + onLiveEvent: (event: LiveEvent) => void; + params?: { + [key: string]: any; + }; + types?: LiveEvent["type"][]; + enabled?: boolean; +}; + +export const useSubscription = ({ + params, + channel, + types = ["*"], + enabled = true, + onLiveEvent, +}: UseSubscriptionProps): void => { + const liveDataContext = useContext(LiveContext); + + useEffect(() => { + let subscription: any; + + if (enabled) { + subscription = liveDataContext?.subscribe({ + channel, + params, + types, + callback: onLiveEvent, + }); + } + + return () => { + if (subscription) { + liveDataContext?.unsubscribe(subscription); + } + }; + }, [enabled]); +}; diff --git a/packages/core/src/hooks/show/useShow.ts b/packages/core/src/hooks/show/useShow.ts index 1be234982bbd..220b32440b2d 100644 --- a/packages/core/src/hooks/show/useShow.ts +++ b/packages/core/src/hooks/show/useShow.ts @@ -9,6 +9,8 @@ import { GetOneResponse, SuccessErrorNotification, MetaDataQuery, + LiveEvent, + LiveModeProps, } from "../../interfaces"; export type useShowReturnType = { @@ -21,7 +23,8 @@ export type useShowProps = { resource?: string; id?: string; metaData?: MetaDataQuery; -} & SuccessErrorNotification; +} & LiveModeProps & + SuccessErrorNotification; /** * `useShow` hook allows you to fetch the desired record. @@ -36,6 +39,8 @@ export const useShow = ({ successNotification, errorNotification, metaData, + liveMode, + onLiveEvent, }: useShowProps = {}): useShowReturnType => { const { useParams } = useRouterContext(); @@ -57,6 +62,8 @@ export const useShow = ({ successNotification, errorNotification, metaData, + liveMode, + onLiveEvent, }); return { diff --git a/packages/core/src/hooks/table/useTable/useTable.ts b/packages/core/src/hooks/table/useTable/useTable.ts index 34b864c3ab7e..bd50b9571a09 100644 --- a/packages/core/src/hooks/table/useTable/useTable.ts +++ b/packages/core/src/hooks/table/useTable/useTable.ts @@ -14,6 +14,7 @@ import { useNavigation, useResourceWithRoute, useList, + useLiveMode, } from "@hooks"; import { stringifyTableParams, @@ -33,6 +34,7 @@ import { SuccessErrorNotification, HttpError, MetaDataQuery, + LiveModeProps, } from "../../../interfaces"; export type useTableProps = { @@ -46,7 +48,8 @@ export type useTableProps = { onSearch?: (data: TSearchVariables) => CrudFilters | Promise; queryOptions?: UseQueryOptions, TError>; metaData?: MetaDataQuery; -} & SuccessErrorNotification; +} & SuccessErrorNotification & + LiveModeProps; export type useTableReturnType< TData extends BaseRecord = BaseRecord, @@ -85,6 +88,9 @@ export const useTable = < successNotification, errorNotification, queryOptions, + liveMode: liveModeFromProp, + onLiveEvent, + liveParams, metaData, }: useTableProps = {}): useTableReturnType< TData, @@ -105,6 +111,7 @@ export const useTable = < const { useLocation, useParams } = useRouterContext(); const { search } = useLocation(); + const liveMode = useLiveMode(liveModeFromProp); let defaultCurrent = initialCurrent; let defaultPageSize = initialPageSize; @@ -182,8 +189,11 @@ export const useTable = < successNotification, errorNotification, metaData, + liveMode, + liveParams, + onLiveEvent, }); - const { data, isFetching } = queryResult; + const { data, isFetching, isLoading } = queryResult; const onChange = ( pagination: TablePaginationConfig, @@ -230,7 +240,8 @@ export const useTable = < tableProps: { ...tablePropsSunflower, dataSource: data?.data, - loading: isFetching, + loading: liveMode ? isLoading : isFetching, + // loading: isFetching, onChange, pagination: { ...tablePropsSunflower.pagination, diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index 8f0742589025..8b8edab8a90c 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -7,6 +7,8 @@ export * from "./hooks"; export { IAuthContext as AuthProvider, Pagination } from "./interfaces"; export { IDataContextProvider as DataProvider, + ILiveContext as LiveProvider, + LiveEvent, ITranslationContext as TranslationProvider, IAccessControlContext as AccessControlProvider, I18nProvider, diff --git a/packages/core/src/interfaces/index.ts b/packages/core/src/interfaces/index.ts index 0e2f7b6ab187..8fdd36c88de0 100644 --- a/packages/core/src/interfaces/index.ts +++ b/packages/core/src/interfaces/index.ts @@ -7,6 +7,7 @@ export * from "./istate"; // contexts export * from "../contexts/data/IDataContext"; +export * from "../contexts/live/ILiveContext"; export * from "../contexts/auth/IAuthContext"; export * from "../contexts/refine/IRefineContext"; export * from "../contexts/translation/ITranslationContext"; @@ -61,6 +62,9 @@ export * from "./successErrorNotification"; //metaData export * from "./metaData"; +//metaData +export * from "./live"; + // sunflower types export { useModalFormFromSFReturnType, diff --git a/packages/core/src/interfaces/live/LiveEvent.ts b/packages/core/src/interfaces/live/LiveEvent.ts new file mode 100644 index 000000000000..324fcd537786 --- /dev/null +++ b/packages/core/src/interfaces/live/LiveEvent.ts @@ -0,0 +1,9 @@ +export type LiveEvent = { + channel: string; + type: "deleted" | "updated" | "created" | "*" | string; + payload: { + ids?: string[]; + [x: string]: any; + }; + date: Date; +}; diff --git a/packages/core/src/interfaces/live/LiveModeProps.ts b/packages/core/src/interfaces/live/LiveModeProps.ts new file mode 100644 index 000000000000..f232af80a684 --- /dev/null +++ b/packages/core/src/interfaces/live/LiveModeProps.ts @@ -0,0 +1,12 @@ +import { LiveEvent } from "."; + +export type LiveModeProps = { + liveMode?: "auto" | "manual" | "off"; + onLiveEvent?: (event: LiveEvent) => void; + liveParams?: { + ids?: string[]; + [key: string]: any; + }; +}; + +export type ILiveModeContextProvider = LiveModeProps; diff --git a/packages/core/src/interfaces/live/index.ts b/packages/core/src/interfaces/live/index.ts new file mode 100644 index 000000000000..50b2b66fbe0c --- /dev/null +++ b/packages/core/src/interfaces/live/index.ts @@ -0,0 +1,2 @@ +export * from "./LiveEvent"; +export * from "./LiveModeProps"; diff --git a/packages/core/test/dataMocks.ts b/packages/core/test/dataMocks.ts index f00d08ff9138..75bc470b1b98 100644 --- a/packages/core/test/dataMocks.ts +++ b/packages/core/test/dataMocks.ts @@ -4,6 +4,7 @@ import { IDataContext, IRouterContext, IAccessControlContext, + ILiveContext, } from "../src/interfaces"; export const posts = [ @@ -60,3 +61,9 @@ export const MockRouterProvider: IRouterContext = { export const MockAccessControlProvider: IAccessControlContext = { can: () => Promise.resolve({ can: true }), }; + +export const MockLiveProvider: ILiveContext = { + subscribe: () => ({}), + unsubscribe: () => ({}), + publish: () => ({}), +}; diff --git a/packages/core/test/index.tsx b/packages/core/test/index.tsx index 68e52328a80d..dc621251944e 100644 --- a/packages/core/test/index.tsx +++ b/packages/core/test/index.tsx @@ -11,14 +11,20 @@ import { IAuthContext, I18nProvider, IAccessControlContext, + ILiveContext, } from "../src/interfaces"; import { TranslationContextProvider } from "@contexts/translation"; import { RefineContextProvider } from "@contexts/refine"; import { IRefineContextProvider } from "@contexts/refine/IRefineContext"; import { RouterContextProvider } from "@contexts/router"; import { AccessControlContextProvider } from "@contexts/accessControl"; +import { LiveContextProvider } from "@contexts/live"; -import { MockRouterProvider, MockAccessControlProvider } from "@test"; +import { + MockRouterProvider, + MockAccessControlProvider, + MockLiveProvider, +} from "@test"; const queryClient = new QueryClient({ defaultOptions: { @@ -34,6 +40,7 @@ interface ITestWrapperProps { dataProvider?: IDataContext; i18nProvider?: I18nProvider; accessControlProvider?: IAccessControlContext; + liveProvider?: ILiveContext; resources?: IResourceItem[]; children?: React.ReactNode; routerInitialEntries?: string[]; @@ -48,6 +55,7 @@ export const TestWrapper: (props: ITestWrapperProps) => React.FC = ({ accessControlProvider, routerInitialEntries, refineProvider, + liveProvider, }) => { // eslint-disable-next-line react/display-name return ({ children }): React.ReactElement => { @@ -77,12 +85,22 @@ export const TestWrapper: (props: ITestWrapperProps) => React.FC = ({ ); + const withLive = liveProvider ? ( + + {withAccessControl} + + ) : ( + + {withAccessControl} + + ); + const withTranslation = i18nProvider ? ( - {withAccessControl} + {withLive} ) : ( - withAccessControl + withLive ); const withNotification = ( @@ -123,6 +141,7 @@ export { MockJSONServer, MockRouterProvider, MockAccessControlProvider, + MockLiveProvider, } from "./dataMocks"; // re-export everything diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 837091d9736e..6a7575bad2a7 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -60,7 +60,7 @@ }, "typeRoots": [ "./types", - "./node_modules/@types" + "../../node_modules/@types" ] } } \ No newline at end of file diff --git a/packages/supabase/src/index.ts b/packages/supabase/src/index.ts index c6f5a80e2884..da946a33b30e 100644 --- a/packages/supabase/src/index.ts +++ b/packages/supabase/src/index.ts @@ -1,6 +1,32 @@ -import { DataProvider } from "@pankod/refine"; -import { CrudFilter } from "@pankod/refine/dist/interfaces"; -import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import { + DataProvider, + LiveProvider, + CrudFilter, + LiveEvent, +} from "@pankod/refine"; +import { + createClient, + RealtimeSubscription, + SupabaseClient, +} from "@supabase/supabase-js"; +import { + SupabaseEventTypes, + SupabaseRealtimePayload, +} from "@supabase/supabase-js/dist/main/lib/types"; + +const liveTypes: Record = { + INSERT: "created", + UPDATE: "updated", + DELETE: "deleted", + "*": "*", +}; + +const supabaseTypes: Record = { + created: "INSERT", + updated: "UPDATE", + deleted: "DELETE", + "*": "*", +}; const generateFilter = (filter: CrudFilter, query: any) => { switch (filter.operator) { @@ -158,4 +184,60 @@ const dataProvider = (supabaseClient: SupabaseClient): DataProvider => { }; }; -export { dataProvider, createClient }; +const liveProvider = (supabaseClient: SupabaseClient): LiveProvider => { + return { + subscribe: ({ + channel, + types, + params, + callback, + }): RealtimeSubscription => { + const resource = channel.replace("resources/", ""); + + const listener = (payload: SupabaseRealtimePayload) => { + if ( + types.includes("*") || + types.includes(liveTypes[payload.eventType]) + ) { + if ( + liveTypes[payload.eventType] !== "created" && + params?.ids !== undefined && + payload.new?.id !== undefined + ) { + if (params.ids.includes(payload.new.id.toString())) { + callback({ + channel, + type: liveTypes[payload.eventType], + date: new Date(payload.commit_timestamp), + payload: payload.new, + }); + } + } else { + callback({ + channel, + type: liveTypes[payload.eventType], + date: new Date(payload.commit_timestamp), + payload: payload.new, + }); + } + } + }; + + const client = supabaseClient + .from(resource) + .on(supabaseTypes[types[0]], listener); + + types + .slice(1) + .map((item) => client.on(supabaseTypes[item], listener)); + + return client.subscribe(); + }, + + unsubscribe: async (subscription: RealtimeSubscription) => { + supabaseClient.removeSubscription(subscription); + }, + }; +}; + +export { dataProvider, liveProvider, createClient };