Panels: per-device dashboard sets, paired by code
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
A panel is one screen and the ordered set of whole dashboards it shows, so a hallway tablet and a workshop tablet carry different sets without either dashboard knowing about the other. More than one and the device draws a rail to switch between them — the same rail the editor puts on screen, because the wall has it and it takes room off the canvas. A screen has no keyboard, so it pairs rather than logs in: it shows a six-character code, somebody approves it against a panel from the dashboards overview, and the credential that mints reaches that panel's published dashboards and the message endpoints its widgets speak, and nothing else. Deleting the panel revokes it. Closes the per-device view and the kiosk credential; supersedes the multi-page/multi-section UI, since a page is now a dashboard of its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHpLJHozysQXjsxAyU1WHj
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useNavigate } from "@tanstack/react-router"
|
||||
import {
|
||||
Check,
|
||||
@@ -68,9 +68,11 @@ import {
|
||||
sectionsOf,
|
||||
widgetsOf,
|
||||
} from "./DashboardView"
|
||||
import { PanelRail, RAIL_INSET } from "./PanelRail"
|
||||
import { DashboardPanel, WidgetPanel } from "./panels"
|
||||
import {
|
||||
dashboardKeys,
|
||||
panelsQueryOptions,
|
||||
useDiscardDashboardDraft,
|
||||
usePublishDashboard,
|
||||
useSaveDashboard,
|
||||
@@ -351,6 +353,18 @@ export function DashboardEditor({
|
||||
const active = widgets.find((widget) => widget.id === selected) ?? null
|
||||
const panelOpen = Boolean(active) || settingsOpen
|
||||
|
||||
// A dashboard hanging on a panel beside others is drawn with a rail over it,
|
||||
// which takes room off the canvas. Show that here rather than letting someone
|
||||
// arrange against a width the wall does not have.
|
||||
// ponytail: the first such panel wins — the others differ only in scale.
|
||||
const { data: panels } = useQuery(panelsQueryOptions())
|
||||
const railDashboards =
|
||||
(panels?.panels ?? []).find(
|
||||
(panel) =>
|
||||
(panel.dashboards ?? []).length > 1 &&
|
||||
(panel.dashboards ?? []).includes(draft.name),
|
||||
)?.dashboards ?? null
|
||||
|
||||
const setEdit = (next: boolean) => {
|
||||
setSelected(null)
|
||||
setSettingsOpen(false)
|
||||
@@ -442,11 +456,28 @@ export function DashboardEditor({
|
||||
|
||||
return (
|
||||
<>
|
||||
{railDashboards && !stacked ? (
|
||||
<PanelRail
|
||||
dashboards={railDashboards}
|
||||
current={draft.name}
|
||||
// Editing one dashboard of a panel and editing its neighbour are the
|
||||
// same job, so the rail keeps whichever mode this one is in.
|
||||
linkFor={(name) => ({
|
||||
to: "/dashboards/$name",
|
||||
params: { name },
|
||||
search: edit ? { edit: true } : {},
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 overflow-hidden px-4 pb-24 pt-20 transition-[padding] duration-200",
|
||||
panelOpen && "md:pr-[27rem]",
|
||||
)}
|
||||
style={
|
||||
railDashboards && !stacked ? { paddingLeft: RAIL_INSET } : undefined
|
||||
}
|
||||
data-testid="dashboard-canvas"
|
||||
>
|
||||
{body}
|
||||
@@ -457,6 +488,7 @@ export function DashboardEditor({
|
||||
"pointer-events-none absolute inset-0 transition-[right] duration-200",
|
||||
panelOpen && "md:right-[27rem]",
|
||||
)}
|
||||
style={railDashboards && !stacked ? { left: RAIL_INSET } : undefined}
|
||||
>
|
||||
<CanvasTitle>
|
||||
<span className="truncate px-3 py-1.5 text-sm font-medium">
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useQueries } from "@tanstack/react-query"
|
||||
import { Link, type LinkProps } from "@tanstack/react-router"
|
||||
|
||||
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/** How much room the rail takes, canvas insets included. Mirrors the side
|
||||
* panel's `27rem`: the button plus the gutters either side of it. */
|
||||
export const RAIL_INSET = "4.5rem"
|
||||
|
||||
/** Two letters off the title, so a rail of four reads as four different things. */
|
||||
function initials(label: string): string {
|
||||
const words = label.split(/[\s_-]+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
|
||||
return (words[0][0] + words[1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Switching between the dashboards one panel was assigned.
|
||||
*
|
||||
* Permanent rather than a menu behind a button: a wall panel is glanced at,
|
||||
* and something you have to open first is something nobody opens. It costs
|
||||
* about a fortieth of the canvas, which `CanvasSurface` absorbs by scaling —
|
||||
* the grid itself never changes, so an arrangement made without the rail still
|
||||
* fits with it.
|
||||
*
|
||||
* Reading the dashboards it links to is also what fills the labels, and it
|
||||
* warms the cache for the neighbours so a switch draws immediately.
|
||||
*/
|
||||
export function PanelRail({
|
||||
dashboards,
|
||||
current,
|
||||
linkFor,
|
||||
className,
|
||||
}: {
|
||||
dashboards: string[]
|
||||
current: string
|
||||
linkFor: (name: string) => LinkProps
|
||||
className?: string
|
||||
}) {
|
||||
const titles = useQueries({
|
||||
queries: dashboards.map((name) => dashboardQueryOptions(name)),
|
||||
combine: (results) =>
|
||||
results.map((result, index) => result.data?.title || dashboards[index]),
|
||||
})
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Dashboards on this panel"
|
||||
data-testid="panel-rail"
|
||||
className={cn(
|
||||
"pointer-events-auto absolute inset-y-4 left-4 z-10 flex w-12 flex-col items-center gap-1 overflow-y-auto rounded-lg border border-border bg-card/80 p-1 shadow-e2 backdrop-blur-md",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dashboards.map((name, index) => {
|
||||
const label = titles[index]
|
||||
const active = name === current
|
||||
return (
|
||||
<Tooltip key={name}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
// `icon-lg` at every width on purpose: a rail is a touch
|
||||
// surface whatever the screen's size, and the panel it hangs
|
||||
// on is 1024 wide as often as it is 400.
|
||||
size="icon-lg"
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium text-muted-foreground",
|
||||
active && "bg-accent text-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
data-testid={`panel-rail-${name}`}
|
||||
>
|
||||
<Link {...linkFor(name)} aria-label={label}>
|
||||
{initials(label)}
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Dashboard } from "@/components/Dashboard/DashboardView"
|
||||
import {
|
||||
CanvasSurface,
|
||||
DashboardView,
|
||||
} from "@/components/Dashboard/DashboardView"
|
||||
|
||||
/**
|
||||
* One dashboard filling whatever screen it landed on.
|
||||
*
|
||||
* The whole of what a wall panel draws, shared by the single-dashboard route
|
||||
* (`/view/{name}`) and the paired-panel one (`/panel/{id}`) so a device shows
|
||||
* the same thing either way — the second merely has a rail beside it.
|
||||
*/
|
||||
export function PanelSurface({
|
||||
dashboard,
|
||||
stacked,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
/** A landscape arrangement scaled onto a phone comes out at about a fifth of
|
||||
* its size, which reads as nothing at all. Stack it instead. */
|
||||
stacked?: boolean
|
||||
}) {
|
||||
if (stacked) return <DashboardView dashboard={dashboard} stacked />
|
||||
|
||||
// The panel's own surface, scaled to fit. No dots: nothing is being
|
||||
// arranged here.
|
||||
return (
|
||||
<CanvasSurface dashboard={dashboard}>
|
||||
{() => <DashboardView dashboard={dashboard} />}
|
||||
</CanvasSurface>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import {
|
||||
type ApiError,
|
||||
type PanelDef,
|
||||
type PanelsConfig,
|
||||
PanelsService,
|
||||
} from "@/client"
|
||||
import {
|
||||
dashboardsQueryOptions,
|
||||
panelsQueryOptions,
|
||||
useSavePanels,
|
||||
} from "@/components/Dashboard/queries"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
/** The store only accepts this shape, so say so before the request does. */
|
||||
const slugify = (value: string) =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
|
||||
/**
|
||||
* Which dashboards hang on which screen, and adopting the screens themselves.
|
||||
*
|
||||
* A panel is a device rather than a document: it has no draft and nothing to
|
||||
* publish, so it lives in a dialog over the dashboards list instead of a page
|
||||
* of its own. Every change saves as it is made — there is no form to submit.
|
||||
*/
|
||||
export function PanelsDialog() {
|
||||
const { data: config } = useQuery(panelsQueryOptions())
|
||||
const { data: dashboards } = useQuery(dashboardsQueryOptions())
|
||||
const save = useSavePanels()
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const [name, setName] = useState("")
|
||||
|
||||
const panels = config?.panels ?? []
|
||||
const known = dashboards?.data ?? []
|
||||
|
||||
const write = (next: PanelsConfig) =>
|
||||
save.mutate(next, {
|
||||
onError: (error) => handleError.call(showErrorToast, error as ApiError),
|
||||
})
|
||||
|
||||
const replace = (id: string, panel: PanelDef) =>
|
||||
write({ panels: panels.map((p) => (p.id === id ? panel : p)) })
|
||||
|
||||
const newId = slugify(name)
|
||||
const taken = panels.some((panel) => panel.id === newId)
|
||||
|
||||
return (
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Panels</DialogTitle>
|
||||
<DialogDescription>
|
||||
A panel is one screen and the dashboards it shows. Point the device at
|
||||
the link, and it asks for a code you enter here.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{panels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No panels yet. Add one below.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{panels.map((panel) => (
|
||||
<PanelRow
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
dashboards={known.map((dashboard) => ({
|
||||
name: dashboard.name,
|
||||
title: dashboard.title || dashboard.name,
|
||||
}))}
|
||||
onChange={(next) => replace(panel.id, next)}
|
||||
onRemove={() =>
|
||||
write({ panels: panels.filter((p) => p.id !== panel.id) })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<form
|
||||
className="flex items-end gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!newId || taken) return
|
||||
write({ panels: [...panels, { id: newId, title: name.trim() }] })
|
||||
setName("")
|
||||
}}
|
||||
>
|
||||
<div className="grid flex-1 gap-1">
|
||||
<label className="text-sm" htmlFor="new-panel">
|
||||
New panel
|
||||
</label>
|
||||
<Input
|
||||
id="new-panel"
|
||||
value={name}
|
||||
placeholder="hallway"
|
||||
autoComplete="off"
|
||||
data-testid="new-panel-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!newId || taken}
|
||||
data-testid="add-panel"
|
||||
>
|
||||
Add panel
|
||||
</Button>
|
||||
</form>
|
||||
{taken ? (
|
||||
<p className="text-sm text-destructive">
|
||||
There is already a panel called {newId}.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
function PanelRow({
|
||||
panel,
|
||||
dashboards,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
panel: PanelDef
|
||||
dashboards: { name: string; title: string }[]
|
||||
onChange: (next: PanelDef) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const [code, setCode] = useState("")
|
||||
const assigned = panel.dashboards ?? []
|
||||
|
||||
const pair = useMutation({
|
||||
mutationFn: () =>
|
||||
PanelsService.approvePairing({
|
||||
panelId: panel.id,
|
||||
requestBody: { code: code.trim().toUpperCase() },
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setCode("")
|
||||
showSuccessToast(
|
||||
"Paired — the screen switches over within a few seconds.",
|
||||
)
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
const toggle = (dashboard: string) =>
|
||||
onChange({
|
||||
...panel,
|
||||
// ponytail: order follows the order they were ticked. Arrows if anyone
|
||||
// asks for them.
|
||||
dashboards: assigned.includes(dashboard)
|
||||
? assigned.filter((name) => name !== dashboard)
|
||||
: [...assigned, dashboard],
|
||||
})
|
||||
|
||||
const link = `${window.location.origin}/panel/${panel.id}`
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={panel.title}
|
||||
placeholder={panel.id}
|
||||
aria-label={`Title of ${panel.id}`}
|
||||
onChange={(event) =>
|
||||
onChange({ ...panel, title: event.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
{panel.id}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 shrink-0 text-muted-foreground md:size-8"
|
||||
aria-label={`Remove ${panel.id}`}
|
||||
data-testid={`remove-panel-${panel.id}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{dashboards.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No dashboards to assign yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{dashboards.map((dashboard) => {
|
||||
const position = assigned.indexOf(dashboard.name)
|
||||
const id = `assign-${panel.id}-${dashboard.name}`
|
||||
return (
|
||||
<label
|
||||
key={dashboard.name}
|
||||
htmlFor={id}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={position >= 0}
|
||||
data-testid={id}
|
||||
onCheckedChange={() => toggle(dashboard.name)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{dashboard.title}</span>
|
||||
{position >= 0 ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{position + 1}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={link}
|
||||
aria-label={`Link for ${panel.id}`}
|
||||
className="text-muted-foreground"
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (code.trim()) pair.mutate()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={code}
|
||||
placeholder="Code shown on the screen"
|
||||
aria-label={`Pairing code for ${panel.id}`}
|
||||
autoComplete="off"
|
||||
maxLength={6}
|
||||
data-testid={`pair-code-${panel.id}`}
|
||||
onChange={(event) => setCode(event.target.value.toUpperCase())}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!code.trim() || pair.isPending}
|
||||
data-testid={`pair-${panel.id}`}
|
||||
>
|
||||
Pair device
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type DashboardDef_Input,
|
||||
DashboardsService,
|
||||
MessagesService,
|
||||
type PanelsConfig,
|
||||
PanelsService,
|
||||
} from "@/client"
|
||||
|
||||
export const dashboardKeys = {
|
||||
@@ -31,6 +33,41 @@ export const dashboardQueryOptions = (name: string, draft = false) => ({
|
||||
queryFn: () => DashboardsService.readDashboard({ name, draft }),
|
||||
})
|
||||
|
||||
export const panelKeys = {
|
||||
all: ["panels"] as const,
|
||||
detail: (id: string) => ["panels", id] as const,
|
||||
}
|
||||
|
||||
/** Every panel and what it shows. Read by the editor to know if a rail is due. */
|
||||
export const panelsQueryOptions = () => ({
|
||||
queryKey: panelKeys.all,
|
||||
queryFn: () => PanelsService.readPanels(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One panel, as the device hanging on the wall reads it.
|
||||
*
|
||||
* A panel credential may read exactly this and the dashboards it names, so
|
||||
* this is the query the panel route is built on rather than the list above.
|
||||
*/
|
||||
export const panelQueryOptions = (id: string) => ({
|
||||
queryKey: panelKeys.detail(id),
|
||||
queryFn: () => PanelsService.readPanel({ panelId: id }),
|
||||
})
|
||||
|
||||
/** Replace the panels. Superuser-only on the server. */
|
||||
export function useSavePanels() {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: PanelsConfig) =>
|
||||
PanelsService.savePanels({ requestBody: body }),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(panelKeys.all, saved)
|
||||
queryClient.invalidateQueries({ queryKey: panelKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Every message any flow declares — what a widget can be pointed at. */
|
||||
export const messageCatalogQueryOptions = () => ({
|
||||
queryKey: dashboardKeys.messages,
|
||||
|
||||
Reference in New Issue
Block a user