diff --git a/docs/CheckForApplicationUpdate.md b/docs/CheckForApplicationUpdate.md new file mode 100644 index 00000000000..c4942d2bada --- /dev/null +++ b/docs/CheckForApplicationUpdate.md @@ -0,0 +1,146 @@ +--- +layout: default +title: "The CheckForApplicationUpdate component" +--- + +# `CheckForApplicationUpdate` + +When your admin application is a Single Page Application, users who keep a browser tab open at all times might not use the most recent version of the application unless you tell them to refresh the page. + +This component regularly checks whether the application source code has changed and prompts users to reload the page when an update is available. To detect updates, it fetches the current URL at regular intervals and compares the hash of the response content (usually the HTML source). This should be enough in most cases as bundlers usually update the links to the application bundles after an update. + +![CheckForApplicationUpdate](./img/CheckForApplicationUpdate.png) + +## Usage + +Include this component in a custom layout: + +```tsx +// in src/MyLayout.tsx +import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin'; + +export const MyLayout = ({ children, ...props }: LayoutProps) => ( + + {children} + + +); + +// in src/App.tsx +import { Admin, ListGuesser, Resource } from 'react-admin'; +import { MyLayout } from './MyLayout'; + +export const App = () => ( + + + +); +``` + +## Props + +`` accepts the following props: + +| Prop | Required | Type | Default | Description | +| --------------- | -------- | -------- | ------------------ |-------------------------------------------------------------------- | +| `interval` | Optional | number | `3600000` (1 hour) | The interval in milliseconds between two checks | +| `disabled` | Optional | boolean | `false` in `production` mode | Whether the automatic check is disabled | +| `notification` | Optional | ReactElement | | The notification to display to the user when an update is available | +| `url` | Optional | string | current URL | The URL to download to check for code update | + +## `interval` + +You can customize the interval between each check by providing the `interval` prop. It accepts a number of milliseconds and is set to `3600000` (1 hour) by default. + +```tsx +// in src/MyLayout.tsx +import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin'; + +const HALF_HOUR = 30 * 60 * 1000; + +export const MyLayout = ({ children, ...props }: LayoutProps) => ( + + {children} + + +); +``` + +## `disabled` + +You can dynamically disable the automatic application update detection by providing the `disabled` prop. By default, it's only enabled in `production` mode. + +```tsx +// in src/MyLayout.tsx +import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin'; + +export const MyLayout = ({ children, ...props }: LayoutProps) => ( + + {children} + + +); +``` + +## `notification` + +You can customize the notification shown to users when an update is available by passing your own element to the `notification` prop. +Note that you must wrap your component with `forwardRef`. + +```tsx +// in src/MyLayout.tsx +import { forwardRef } from 'react'; +import { Layout, CheckForApplicationUpdate } from 'react-admin'; + +const CustomAppUpdatedNotification = forwardRef((props, ref) => ( + window.location.reload()} + > + Update + + } + > + A new version of the application is available. Please update. + +)); + +const MyLayout = ({ children, ...props }) => ( + + {children} + }/> + +); +``` + +If you just want to customize the notification texts, including the button, check out the [Internationalization section](#internationalization). + +## `url` + +You can customize the URL fetched to detect updates by providing the `url` prop. By default it's the current URL. + +```tsx +// in src/MyLayout.tsx +import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin'; + +const MY_APP_ROOT_URL = 'http://admin.mycompany.com'; + +export const MyLayout = ({ children, ...props }: LayoutProps) => ( + + {children} + + +); +``` + +## Internationalization + +You can customize the texts of the default notification by overriding the following keys: + +* `ra.notification.application_update_available`: the notification text +* `ra.action.update_application`: the reload button text \ No newline at end of file diff --git a/docs/CreateReactApp.md b/docs/CreateReactApp.md index 3a5ce3eabfd..c2b1455736a 100644 --- a/docs/CreateReactApp.md +++ b/docs/CreateReactApp.md @@ -66,3 +66,43 @@ Now, start the server with `yarn start`, browse to `http://localhost:3000/`, and ![Working Page](./img/nextjs-react-admin.webp) Your app is now up and running, you can start tweaking it. + +## Ensuring Users Have The Latest Version + +If your users might keep the application open for a long time, it's a good idea to add the [``](./CheckForApplicationUpdate.md) component. It will check whether a more recent version of your application is available and prompt users to reload their browser tab. + +To determine whether your application has been updated, it fetches the current page at a regular interval, builds an hash of the response content (usually the HTML) and compares it with the previous hash. + +To enable it, start by creating a custom layout: + +```tsx +// in src/admin/MyLayout.tsx +import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin'; + +export const MyLayout = ({ children, ...props }: LayoutProps) => ( + + {children} + + +); +``` + +Then use this layout in your app: + +```diff +import { Admin, Resource, ListGuesser } from "react-admin"; +import jsonServerProvider from "ra-data-json-server"; ++import { MyLayout } from './MyLayout'; + +const dataProvider = jsonServerProvider("https://jsonplaceholder.typicode.com"); + +const App = () => ( +- ++ + + + +); + +export default App; +``` \ No newline at end of file diff --git a/docs/Reference.md b/docs/Reference.md index e7de7f2bcda..75d91c35dd8 100644 --- a/docs/Reference.md +++ b/docs/Reference.md @@ -32,6 +32,7 @@ title: "Index" **- C -** * [``](./Calendar.md) * [``](./CheckboxGroupInput.md) +* [``](./CheckForApplicationUpdate.md) * [``](./ChipField.md) * [``](./CloneButton.md) * [``](https://marmelab.com/ra-enterprise/modules/ra-calendar#completecalendar) diff --git a/docs/Vite.md b/docs/Vite.md index cdb117c23f1..2eec4ae9ed6 100644 --- a/docs/Vite.md +++ b/docs/Vite.md @@ -100,6 +100,46 @@ Now, start the server with `yarn dev`, browse to `http://localhost:5173/`, and y Your app is now up and running, you can start tweaking it. +## Ensuring Users Have The Latest Version + +If your users might keep the application open for a long time, it's a good idea to add the [``](./CheckForApplicationUpdate.md) component. It will check whether a more recent version of your application is available and prompt users to reload their browser tab. + +To determine whether your application has been updated, it fetches the current page at a regular interval, builds an hash of the response content (usually the HTML) and compares it with the previous hash. + +To enable it, start by creating a custom layout: + +```tsx +// in src/admin/MyLayout.tsx +import { CheckForApplicationUpdate, Layout, LayoutProps } from 'react-admin'; + +export const MyLayout = ({ children, ...props }: LayoutProps) => ( + + {children} + + +); +``` + +Then use this layout in your app: + +```diff +import { Admin, Resource, ListGuesser } from "react-admin"; +import jsonServerProvider from "ra-data-json-server"; ++import { MyLayout } from './MyLayout'; + +const dataProvider = jsonServerProvider("https://jsonplaceholder.typicode.com"); + +const App = () => ( +- ++ + + + +); + +export default App; +``` + ## Troubleshooting ### Error about `global` Being `undefined` diff --git a/docs/img/CheckForApplicationUpdate.png b/docs/img/CheckForApplicationUpdate.png new file mode 100644 index 00000000000..98993ff4f84 Binary files /dev/null and b/docs/img/CheckForApplicationUpdate.png differ diff --git a/docs/navigation.html b/docs/navigation.html index 0e3a0efc02d..69d2e9ecd06 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -243,6 +243,7 @@
  • <Search>
  • <Confirm>
  • Buttons
  • +
  • <CheckForApplicationUpdate>
  • <UpdateButton>
  • <RecordRepresentation>
  • useGetRecordRepresentation
  • diff --git a/examples/crm/src/Layout.tsx b/examples/crm/src/Layout.tsx index 1e564f22eec..99f68c1f70a 100644 --- a/examples/crm/src/Layout.tsx +++ b/examples/crm/src/Layout.tsx @@ -1,6 +1,6 @@ import React, { HtmlHTMLAttributes } from 'react'; import { CssBaseline, Container } from '@mui/material'; -import { CoreLayoutProps } from 'react-admin'; +import { CoreLayoutProps, CheckForApplicationUpdate } from 'react-admin'; import { ErrorBoundary } from 'react-error-boundary'; import { Error } from 'react-admin'; @@ -18,6 +18,7 @@ const Layout = ({ children }: LayoutProps) => ( + ); diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 782e9ec1ff6..c3aa327aeec 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -31,3 +31,4 @@ export * from './shallowEqual'; export * from './LabelPrefixContext'; export * from './LabelPrefixContextProvider'; export * from './useLabelPrefix'; +export * from './useCheckForApplicationUpdate'; diff --git a/packages/ra-core/src/util/useCheckForApplicationUpdate.ts b/packages/ra-core/src/util/useCheckForApplicationUpdate.ts new file mode 100644 index 00000000000..02b7f6a531d --- /dev/null +++ b/packages/ra-core/src/util/useCheckForApplicationUpdate.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react'; +import { useEvent } from './useEvent'; + +/** + * Checks if the application code has changed and calls the provided onNewVersionAvailable function when needed. + * + * It checks for code update by downloading the provided URL (default to the HTML page) and + * comparing the hash of the response with the hash of the current page. + * + * @param {UseCheckForApplicationUpdateOptions} options The options + * @param {Function} options.onNewVersionAvailable The function to call when a new version of the application is available. + * @param {string} options.url Optional. The URL to download to check for code update. Defaults to the current URL. + * @param {number} options.interval Optional. The interval in milliseconds between two checks. Defaults to 3600000 (1 hour). + * @param {boolean} options.disabled Optional. Whether the check should be disabled. Defaults to false. + */ +export const useCheckForApplicationUpdate = ( + options: UseCheckForApplicationUpdateOptions +) => { + const { + url = window.location.href, + interval: delay = ONE_HOUR, + onNewVersionAvailable: onNewVersionAvailableProp, + disabled = process.env.NODE_ENV !== 'production', + } = options; + const currentHash = useRef(); + const onNewVersionAvailable = useEvent(onNewVersionAvailableProp); + + useEffect(() => { + if (disabled) return; + + getHashForUrl(url).then(hash => { + if (hash != null) { + currentHash.current = hash; + } + }); + }, [disabled, url]); + + useEffect(() => { + if (disabled) return; + + const interval = setInterval(() => { + getHashForUrl(url) + .then(hash => { + if (hash != null && currentHash.current !== hash) { + // Store the latest hash to avoid calling the onNewVersionAvailable function multiple times + // or when users have closed the notification + currentHash.current = hash; + onNewVersionAvailable(); + } + }) + .catch(() => { + // Ignore errors to avoid issues when connectivity is lost + }); + }, delay); + return () => clearInterval(interval); + }, [delay, onNewVersionAvailable, disabled, url]); +}; + +const getHashForUrl = async (url: string) => { + try { + const response = await fetch(url); + if (!response.ok) return null; + const text = await response.text(); + return hash(text); + } catch (e) { + return null; + } +}; + +// Simple hash function, taken from https://stackoverflow.com/a/52171480/3723993, suggested by Copilot +const hash = (value: string, seed = 0) => { + let h1 = 0xdeadbeef ^ seed, + h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < value.length; i++) { + ch = value.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); + + return 4294967296 * (2097151 & h2) + (h1 >>> 0); +}; + +const ONE_HOUR = 1000 * 60 * 60; + +export interface UseCheckForApplicationUpdateOptions { + onNewVersionAvailable: () => void; + interval?: number; + url?: string; + disabled?: boolean; +} diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 94d2199b2d5..da7fcd97502 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -40,6 +40,7 @@ const englishMessages: TranslationMessages = { open: 'Open', toggle_theme: 'Toggle Theme', select_columns: 'Columns', + update_application: 'Reload Application', }, boolean: { true: 'Yes', @@ -158,6 +159,7 @@ const englishMessages: TranslationMessages = { canceled: 'Action cancelled', logged_out: 'Your session has ended, please reconnect.', not_authorized: "You're not authorized to access this resource.", + application_update_available: 'A new version is available.', }, validation: { required: 'Required', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index 5704fafcfce..8f21a1938ae 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -41,6 +41,7 @@ const frenchMessages: TranslationMessages = { open: 'Ouvrir', toggle_theme: 'Thème clair/sombre', select_columns: 'Colonnes', + update_application: "Recharger l'application", }, boolean: { true: 'Oui', @@ -165,6 +166,7 @@ const frenchMessages: TranslationMessages = { logged_out: 'Votre session a pris fin, veuillez vous reconnecter.', not_authorized: "Vous n'êtes pas autorisé(e) à accéder à cette ressource.", + application_update_available: 'Une mise à jour est disponible.', }, validation: { required: 'Ce champ est requis', diff --git a/packages/ra-ui-materialui/src/layout/ApplicationUpdatedNotification.tsx b/packages/ra-ui-materialui/src/layout/ApplicationUpdatedNotification.tsx new file mode 100644 index 00000000000..f91cb50ad18 --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/ApplicationUpdatedNotification.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Alert, AlertProps, Button, ButtonProps } from '@mui/material'; +import { useTranslate } from 'ra-core'; + +export const ApplicationUpdatedNotification = React.forwardRef< + HTMLDivElement, + ApplicationUpdatedNotificationProps +>((props, ref) => { + const { + ButtonProps, + updateText = 'ra.action.update_application', + notificationText = 'ra.notification.application_update_available', + ...alertProps + } = props; + const translate = useTranslate(); + + const handleButtonClick = () => { + window.location.reload(); + }; + return ( + + {translate(updateText, { _: updateText })} + + } + {...alertProps} + > + {translate(notificationText, { _: notificationText })} + + ); +}); + +export interface ApplicationUpdatedNotificationProps extends AlertProps { + ButtonProps?: ButtonProps; + notificationText?: string; + updateText?: string; +} diff --git a/packages/ra-ui-materialui/src/layout/CheckForApplicationUpdate.tsx b/packages/ra-ui-materialui/src/layout/CheckForApplicationUpdate.tsx new file mode 100644 index 00000000000..64ba87d3e8c --- /dev/null +++ b/packages/ra-ui-materialui/src/layout/CheckForApplicationUpdate.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import { ReactElement } from 'react'; +import { + useNotify, + UseCheckForApplicationUpdateOptions, + useCheckForApplicationUpdate, +} from 'ra-core'; +import { ApplicationUpdatedNotification } from './ApplicationUpdatedNotification'; + +/** + * Display a notification asking users to reload the page when the application code has changed. + * + * @param {CheckForApplicationUpdateProps} props + * @param {boolean} options.disabled Optional. Whether the check should be disabled. Defaults to false. + * @param {string|ReactElement} props.notification The notification to display to the user. Displayed only if `updateMode` is manual. Defaults to ``. + * @param {string} options.url Optional. The URL to download to check for code update. Defaults to the current URL. + * @param {number} options.interval Optional. The interval in milliseconds between two checks. Defaults to 3600000 (1 hour). + * + * @example Basic usage + * import { Admin, Resource, Layout, CheckForApplicationUpdate, ListGuesser } from 'react-admin'; + * + * const MyLayout = ({ children, ...props }) => ( + * + * {children} + * + * + * ); + * + * const App = () => ( + * + * + * + * ); + * + * @example Custom notification + * import { forwardRef } from 'react'; + * import { Admin, Resource, Layout, CheckForApplicationUpdate, ListGuesser } from 'react-admin'; + * + * const CustomAppUpdatedNotification = forwardRef((props, ref) => ( + * window.location.reload()} + * > + * Update + * + * } + * > + * A new version of the application is available. Please update. + * + * )); + * + * const MyLayout = ({ children, ...props }) => ( + * + * {children} + * } /> + * + * ); + * + * const App = () => ( + * + * + * + * ); + */ +export const CheckForApplicationUpdate = ( + props: CheckForApplicationUpdateProps +) => { + const { notification = DEFAULT_NOTIFICATION, ...rest } = props; + const notify = useNotify(); + + const onNewVersionAvailable = () => { + notify(notification, { + type: 'info', + autoHideDuration: null, + }); + }; + + useCheckForApplicationUpdate({ onNewVersionAvailable, ...rest }); + return null; +}; + +export interface CheckForApplicationUpdateProps + extends Omit { + notification?: ReactElement; +} + +const DEFAULT_NOTIFICATION = ; diff --git a/packages/ra-ui-materialui/src/layout/Notification.tsx b/packages/ra-ui-materialui/src/layout/Notification.tsx index 27ee893481d..97c1d1dc5d5 100644 --- a/packages/ra-ui-materialui/src/layout/Notification.tsx +++ b/packages/ra-ui-materialui/src/layout/Notification.tsx @@ -108,7 +108,13 @@ export const Notification = (props: NotificationProps) => { typeof message === 'string' && translate(message, messageArgs) } - autoHideDuration={autoHideDurationFromMessage || autoHideDuration} + autoHideDuration={ + // Only apply the default autoHideDuration when autoHideDurationFromMessage is undefined + // as 0 and null are valid values + autoHideDurationFromMessage === undefined + ? autoHideDuration + : autoHideDurationFromMessage + } disableWindowBlurListener={undoable} TransitionProps={{ onExited: handleExited }} onClose={handleRequestClose} diff --git a/packages/ra-ui-materialui/src/layout/index.ts b/packages/ra-ui-materialui/src/layout/index.ts index 14daa147cca..69559f9f5ad 100644 --- a/packages/ra-ui-materialui/src/layout/index.ts +++ b/packages/ra-ui-materialui/src/layout/index.ts @@ -1,5 +1,7 @@ export * from './AppBar'; +export * from './ApplicationUpdatedNotification'; export * from './CardContentInner'; +export * from './CheckForApplicationUpdate'; export * from './Confirm'; export * from './DashboardMenuItem'; export * from './DeviceTestWrapper';