@faybl/react-utils
React hooks and components for Faybl web apps.
npm install @faybl/react-utils
Every hook but useSession takes the @faybl/client client as client and
does nothing until it has one; useSession reads it from SessionProvider.
useConnection and SessionProvider need client 4.1 or later. A list of rids
or ids is compared by its content, so a new array with the same ids does not
resubscribe.
import {
useChannelsLatest,
useChannelsSeries,
useConnection,
usePresence,
useSession,
} from '@faybl/react-utils';
const presenceById = usePresence({ client, identityIds });
const latest = useChannelsLatest({ client, rids, commitMs: 1000, onError });
const series = useChannelsSeries({ client, rids, windowSeconds: 3600 });
const connected = useConnection({ client });
const { expired, cause, user, signIn, signOut } = useSession();
| Hook | Returns |
|---|---|
usePresence | { [identityId]: { ts, connected } }, live |
useChannelsLatest | { [rid]: { ts, pl } }, the last stored point, then live |
useChannelsSeries | { [rid]: points }, the last windowSeconds, oldest first, then live |
useConnection | whether the MQTT connection is up |
useSession | the session, see An expired session |
useDeploymentInfo | the tenant's web apps, see Deployment info |
connected and expired are undefined until the client has reported them.
The two channel hooks take two options:
commitMscollects live points and renders once per that many milliseconds. Without it, every point renders as it arrives.onError(error, rid)is called when a history read fails. The hooks report nothing on their own.
An expired session
SessionProvider follows the session behind the client and reacts when
it is gone. Put it around the app once the client exists:
<SessionProvider client={client}>
<App />
</SessionProvider>
It finds an expiry two ways, and each has its own prop:
| Prop | When | Default |
|---|---|---|
expiredOnReturn | the user comes back to the tab; a request is sent then to check | reload |
expiredOnRequest | a request is refused while the user works on the page | dialog |
Each takes reload, dialog or none, also exported as REACTION:
reloadreloads the page. While the identity provider still has a session the user is back on the same page at once; otherwise the sign-in page shows.dialogshowsSessionExpiredDialog, which keeps the page, and whatever the user was typing, until the user chooses to sign in again.nonedoes nothing, for an app that shows the expiry its own way throughuseSession.
An expiry found while the tab is hidden counts as a return and waits until the
user comes back. After an automatic reload, a tab reloads on its own again only
once reloadCooldownMs (default 30 000) has passed; an expiry before that shows
the dialog, so a 401 that is not about the session cannot reload in a loop.
dialogLabels sets the dialog's { title, action }, which are English by
default. The dialog takes its colours from the page's CSS color-scheme, so a
dark app has to declare it, with MUI through <CssBaseline enableColorScheme />.
A tenant can run without sign-in. Give the provider the deployment info's
address as deploymentInfoUrl, and it follows the session and fetches the user
only once the info says sign-in is on; in a tenant without it, it does neither,
and the user menu is not there. Without deploymentInfoUrl sign-in counts as
on.
useSession() returns:
authEnabled: false in a tenant without sign-in, undefined while the deployment info is read.expired: undefined until the client has reported, then whether the session is gone.cause: how the expiry was found,returnedorrequest, also exported asCAUSE.user:{ id, firstName, lastName, email }fromauth.getUserInfo, fetched the first time a component asks and kept. Undefined until it arrives.userUnavailable: true while the user could not be fetched.signIn(): reloads the page.signOut(): goes to the login proxy'ssign_outon the app's own host, which ends the session there and at the identity provider, and comes back to the page the user left, which asks the user to sign in again.signOutUrlon the provider sends it somewhere else instead.
dialogComponent replaces the dialog with one of the app's own, which gets
open, onSignIn and labels. An MUI app passes the one from
@faybl/react-utils/mui, which takes the app's theme:
import { MuiSessionExpiredDialog } from '@faybl/react-utils/mui';
<SessionProvider client={client} dialogComponent={MuiSessionExpiredDialog}>
An app that handles the expiry itself can still use the dialog:
const SessionNotice = () => {
const { expired, signIn } = useSession();
return <SessionExpiredDialog open={expired === true} onSignIn={signIn} />;
};
The top bar
@faybl/react-utils/mui has the pieces of the bar every Faybl app shares,
drawn with MUI and the app's theme. They need @mui/material and
@mui/icons-material.
| Component | What it shows |
|---|---|
MuiUserMenu | the user's avatar, with a menu that shows the account and signs out |
MuiAppSwitcher | the tenant's web apps as a grid of links, the current one marked |
MuiConnectionStatus | Live or Offline, from connected |
MuiColorSchemeToggle | a switch between light and dark |
MuiTopBar | the app's icon, title and content, then the four pieces above |
MuiTopBar puts them in a fixed order: the app's start, icon, title,
subtitle and children on the left, then connection, colour scheme, app switcher and user
menu. Each piece has a prop of its own name. Left out or false, the piece is
not there; true, it is there with its defaults; an object, it is there with
those props. Only userMenu is there by default. Every other prop goes to MUI's
AppBar, so the bar can be position="fixed" or take its own sx.
import { useConnection, useDeploymentInfo } from '@faybl/react-utils';
import { MuiTopBar, MuiUserMenu } from '@faybl/react-utils/mui';
// A customer app: title, connection and the user menu.
<MuiTopBar title="Maskin Mekano" connectionStatus={{ connected: useConnection({ client }) }} />
// An internal tool: also the other apps and the colour scheme.
const info = useDeploymentInfo({ url: `${import.meta.env.BASE_URL}faybl/deployment-info.json` });
<MuiTopBar icon="settings" title="Configuration" appSwitcher={{ apps: info?.apps }} colorSchemeToggle />
// With what the app shows now beside its name.
<MuiTopBar icon="timeline" title="Data streamer" subtitle={dashboardTitle} appSwitcher={{ apps }} />
// A bar of the app's own.
<Toolbar>…<MuiUserMenu picture="gravatar" showAccount={false} /></Toolbar>
icon is a name from the list under Deployment info, the
same one the app has in the app switcher. subtitle is what the app shows now,
such as a dashboard's name. When room runs out the subtitle shortens first,
then the title, both with an ellipsis; the pieces never shrink. A piece keeps its place, hidden, while what it shows is loading, so the
bar does not move when the user or the app list arrives.
MuiUserMenu reads the user from useSession, so it needs SessionProvider
above it. It shows the initials, or with picture="gravatar" the user's
Gravatar, falling back to the initials. Gravatar is opt-in because it sends a
hash of every user's email to an outside service. Beside the avatar it shows
the name and email, except on narrow screens; showAccount={false} leaves them
out. children adds menu items above sign-out. When the user cannot be fetched
the menu shows a plain avatar and still signs out.
MuiConnectionStatus counts only connected === false as offline, since
useConnection is undefined before the first report. On narrow screens it shows
the icon alone.
MuiColorSchemeToggle needs a theme with colorSchemes.
Every piece takes labels, English by default:
| Component | labels |
|---|---|
MuiUserMenu | { account, signOut } |
MuiAppSwitcher | { apps } |
MuiConnectionStatus | { live, offline } |
MuiColorSchemeToggle | { dark, light }, named after the scheme a click switches to |
Deployment info
Each Faybl web app serves what the deployment says about the tenant at
<app path>/faybl/deployment-info.json. useDeploymentInfo({ url }) reads it
once per url:
{
"faybl": { "version": "1.0.0" },
"auth": { "enabled": true },
"apps": [
{
"name": "conf-ui",
"label": "Configuration",
"icon": "settings",
"path": "/conf-ui/",
"version": "1.0.3"
}
]
}
It is undefined while it is read. Without a url, or when the file cannot be
read, it has no apps, so the app switcher is not there; pass no url in local
development. Only these fields are kept, an app without a name, label or a
path on the page's own host is dropped, and at most 50 apps are read.
icon is one of apps, settings, chart, play, dashboard, factory,
map, queryStats, timeline and science; any other becomes apps.