Home mosaic, multi-select delete, offline banner and loading states
- Home puts the dashboards beside the flows: two equal-height columns, capped and scrollable, most recently worked on first. Each tile is a schematic footprint built from the stored widget placements. - Flows and dashboards can be picked by long press or ctrl-click; the create button becomes a trash and one dialog covers the batch. - The offline banner is drawn on the body so it centres on the viewport, and the live socket now releases the offline latch a stray 503 set. - A boot spinner before React's first commit, a router pending screen for code-split pages, and skeletons where an empty list used to flash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
This commit is contained in:
+20
-1
@@ -26,7 +26,7 @@ phone the sidebar collapses to a sheet.
|
|||||||
|
|
||||||
## Home
|
## Home
|
||||||
|
|
||||||
The one screen you leave open. Three things share it.
|
The one screen you leave open. Four things share it.
|
||||||
|
|
||||||
### The brain graph
|
### The brain graph
|
||||||
|
|
||||||
@@ -38,6 +38,18 @@ its label showing so you can see which one it is without hovering.
|
|||||||
|
|
||||||
Each flow also has a switch beside it in the list, which starts and stops it.
|
Each flow also has a switch beside it in the list, which starts and stops it.
|
||||||
|
|
||||||
|
### Flows and dashboards
|
||||||
|
|
||||||
|
Two columns under the graph, exactly as tall as each other, most recently
|
||||||
|
worked on first. The flows column is the list with the switches; the dashboards
|
||||||
|
column is a mosaic, each tile a schematic of that dashboard's layout — blocks
|
||||||
|
where its widgets sit, shaded by what kind of widget each one is. Neither
|
||||||
|
column grows past about six rows: past that it scrolls in place rather than
|
||||||
|
pushing the health block down the page.
|
||||||
|
|
||||||
|
The tiles are a footprint, not a live view. They show you which dashboard is
|
||||||
|
which at a glance; the readings are on the dashboard itself.
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
|
|
||||||
Always answers, degraded or not. The tiles cover:
|
Always answers, degraded or not. The tiles cover:
|
||||||
@@ -70,6 +82,13 @@ creates, and offers **Publish all changes** when several flows have drafts.
|
|||||||
|
|
||||||
Opening one takes you to [the flow editor](flow-editor.md).
|
Opening one takes you to [the flow editor](flow-editor.md).
|
||||||
|
|
||||||
|
### Deleting several at once
|
||||||
|
|
||||||
|
Press and hold a card — or ctrl-click it — to pick it, then tap the rest. While
|
||||||
|
anything is picked, **New flow** in the toolbar becomes a trash button, and it
|
||||||
|
asks once before deleting the lot. Unpicking the last one puts the list back;
|
||||||
|
so does Escape. The Dashboards screen works the same way.
|
||||||
|
|
||||||
## Everything else
|
## Everything else
|
||||||
|
|
||||||
- [The flow editor](flow-editor.md) — the canvas, the code editor, running and
|
- [The flow editor](flow-editor.md) — the canvas, the code editor, running and
|
||||||
|
|||||||
+31
-1
@@ -17,9 +17,39 @@
|
|||||||
} catch (_e) { }
|
} catch (_e) { }
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
|
<!-- The boot spinner, until React's first commit clears `#root`. Styled
|
||||||
|
here with the literal values of --background / --border / --primary
|
||||||
|
because the stylesheet carrying those tokens has not necessarily
|
||||||
|
arrived yet; the theme class above is already on <html>. -->
|
||||||
|
<style>
|
||||||
|
#boot {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
#boot i {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid #e4e4e4;
|
||||||
|
border-top-color: #4a7189;
|
||||||
|
border-radius: 9999px;
|
||||||
|
animation: boot-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
html.dark #boot { background: #0a0a0a; }
|
||||||
|
html.dark #boot i { border-color: #2a2a2a; border-top-color: #7ba3b8; }
|
||||||
|
@keyframes boot-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#boot i { animation-duration: 2.4s; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root">
|
||||||
|
<div id="boot" role="status" aria-label="Loading Fluksio"><i></i></div>
|
||||||
|
</div>
|
||||||
<script type="module" src="./src/main.tsx"></script>
|
<script type="module" src="./src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { WifiOff } from "lucide-react"
|
import { WifiOff } from "lucide-react"
|
||||||
import { AnimatePresence, motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
import { useSyncExternalStore } from "react"
|
import { useSyncExternalStore } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
|
||||||
import { ago } from "@/components/Health/queries"
|
import { ago } from "@/components/Health/queries"
|
||||||
import { connectionStore } from "@/lib/connectionStore"
|
import { connectionStore } from "@/lib/connectionStore"
|
||||||
@@ -15,6 +16,11 @@ import { isPortal } from "@/lib/portal"
|
|||||||
* itself. The screen underneath keeps its last data rather than blanking —
|
* itself. The screen underneath keeps its last data rather than blanking —
|
||||||
* stale readings with a timestamp are more use than an empty page, which is
|
* stale readings with a timestamp are more use than an empty page, which is
|
||||||
* why the banner leads with when we last heard anything.
|
* why the banner leads with when we last heard anything.
|
||||||
|
*
|
||||||
|
* Drawn on `document.body` rather than where it is mounted. `position: fixed`
|
||||||
|
* resolves against the nearest ancestor carrying a transform, a filter or a
|
||||||
|
* `backdrop-filter`, and the shell floats several of those — inside them the
|
||||||
|
* banner centres on the content column instead of the viewport.
|
||||||
*/
|
*/
|
||||||
export function ConnectionBanner() {
|
export function ConnectionBanner() {
|
||||||
const connection = useSyncExternalStore(
|
const connection = useSyncExternalStore(
|
||||||
@@ -24,7 +30,7 @@ export function ConnectionBanner() {
|
|||||||
)
|
)
|
||||||
if (!isPortal()) return null
|
if (!isPortal()) return null
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{connection.offline && (
|
{connection.offline && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -47,6 +53,7 @@ export function ConnectionBanner() {
|
|||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { Link } from "@tanstack/react-router"
|
||||||
|
import { LayoutDashboard } from "lucide-react"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
DashboardDef_Output,
|
||||||
|
DashboardSummary,
|
||||||
|
Placement,
|
||||||
|
WidgetDef,
|
||||||
|
} from "@/client"
|
||||||
|
import { dashboardQueryOptions } from "@/components/Dashboard/queries"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** Columns a dashboard is cut into when its document does not say. */
|
||||||
|
const DEFAULT_COLUMNS = 12
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many tiles are worth a request of their own.
|
||||||
|
*
|
||||||
|
* ponytail: the list endpoint carries no placements, so a footprint means
|
||||||
|
* reading that dashboard's document — cheap for the handful an installation
|
||||||
|
* has, and shared with the editor's own cache. The ceiling is an installation
|
||||||
|
* with dozens: the tiles past this show their name and nothing else, and the
|
||||||
|
* fix would be a stored footprint on `DashboardSummary`.
|
||||||
|
*/
|
||||||
|
const PREVIEWS = 8
|
||||||
|
|
||||||
|
/** Widgets that draw a shape, and widgets that are controls. The rest read out. */
|
||||||
|
const GRAPHIC = new Set(["chart", "forecast", "bar", "gauge"])
|
||||||
|
const INPUT = new Set(["button", "switch", "slider", "input", "dropdown"])
|
||||||
|
|
||||||
|
const shade = (type: string) =>
|
||||||
|
INPUT.has(type)
|
||||||
|
? "bg-muted-foreground/30"
|
||||||
|
: GRAPHIC.has(type)
|
||||||
|
? "bg-primary/45"
|
||||||
|
: "bg-primary/20"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a widget sits, at the width a panel is arranged for.
|
||||||
|
*
|
||||||
|
* The same three-line fallback as `Dashboard/DashboardView`, written out again
|
||||||
|
* rather than imported: that module pulls the whole dashboard chunk, and this
|
||||||
|
* draws a schematic on a screen that shows no dashboards.
|
||||||
|
*/
|
||||||
|
const placement = (widget: WidgetDef): Placement => {
|
||||||
|
const layout = (widget.layout ?? {}) as Record<string, Placement>
|
||||||
|
return layout.lg ?? layout.md ?? layout.sm ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block = {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first page's widgets as one grid.
|
||||||
|
*
|
||||||
|
* Sections are separate grids on the real thing, each starting at its own row
|
||||||
|
* zero, so each is pushed down past the one before it to keep them apart here.
|
||||||
|
*/
|
||||||
|
function blocksOf(dashboard: DashboardDef_Output): {
|
||||||
|
blocks: Block[]
|
||||||
|
rows: number
|
||||||
|
} {
|
||||||
|
const blocks: Block[] = []
|
||||||
|
let offset = 0
|
||||||
|
for (const section of (dashboard.pages ?? [])[0]?.sections ?? []) {
|
||||||
|
let bottom = 0
|
||||||
|
for (const widget of section.widgets ?? []) {
|
||||||
|
const { x = 0, y = 0, w = 3, h = 2 } = placement(widget)
|
||||||
|
blocks.push({
|
||||||
|
id: widget.id,
|
||||||
|
type: widget.type,
|
||||||
|
x: Math.max(0, x),
|
||||||
|
y: Math.max(0, y) + offset,
|
||||||
|
w: Math.max(1, w),
|
||||||
|
h: Math.max(1, h),
|
||||||
|
})
|
||||||
|
bottom = Math.max(bottom, Math.max(0, y) + Math.max(1, h))
|
||||||
|
}
|
||||||
|
offset += bottom
|
||||||
|
}
|
||||||
|
return { blocks, rows: Math.max(1, offset) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a dashboard looks like from across the room: its widgets as blocks,
|
||||||
|
* shaded by what kind of thing each one is.
|
||||||
|
*
|
||||||
|
* A footprint rather than a live render. Nothing here subscribes to a message
|
||||||
|
* or reads a value — recognising "the one with the big chart on the left" is
|
||||||
|
* the whole job, and it has to cost nothing on a screen that is not the
|
||||||
|
* dashboard.
|
||||||
|
*/
|
||||||
|
function Footprint({ dashboard }: { dashboard: DashboardDef_Output }) {
|
||||||
|
const { blocks, rows } = blocksOf(dashboard)
|
||||||
|
const columns = dashboard.columns || DEFAULT_COLUMNS
|
||||||
|
// Before the editor could place things, every widget was written at 0,0;
|
||||||
|
// honouring that would pile the whole page onto one cell.
|
||||||
|
const placed = blocks.some((block) => block.x > 0 || block.y > 0)
|
||||||
|
const area = blocks.reduce((sum, block) => sum + block.w * block.h, 0)
|
||||||
|
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
|
||||||
|
<LayoutDashboard className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="grid aspect-video gap-0.5 overflow-hidden rounded-sm bg-muted p-1"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||||
|
gridTemplateRows: `repeat(${
|
||||||
|
placed ? rows : Math.max(1, Math.ceil(area / columns))
|
||||||
|
}, minmax(0, 1fr))`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{blocks.map((block) => {
|
||||||
|
const width = Math.min(columns, block.w)
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={block.id}
|
||||||
|
className={cn("rounded-[2px]", shade(block.type))}
|
||||||
|
style={
|
||||||
|
placed
|
||||||
|
? {
|
||||||
|
gridColumn: `${Math.min(columns - width, block.x) + 1} / span ${width}`,
|
||||||
|
gridRow: `${block.y + 1} / span ${block.h}`,
|
||||||
|
}
|
||||||
|
: { gridColumn: `span ${width}`, gridRow: `span ${block.h}` }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One dashboard in the mosaic.
|
||||||
|
*
|
||||||
|
* Its own query, so the tiles fill in as their documents arrive instead of the
|
||||||
|
* whole panel waiting for the slowest of them.
|
||||||
|
*/
|
||||||
|
function Tile({
|
||||||
|
dashboard,
|
||||||
|
preview,
|
||||||
|
}: {
|
||||||
|
dashboard: DashboardSummary
|
||||||
|
/** Read the document for a footprint, or settle for the name alone. */
|
||||||
|
preview: boolean
|
||||||
|
}) {
|
||||||
|
// The working copy, which is what the list itself is a summary of, so the
|
||||||
|
// preview shows what an editor would open rather than the last publish.
|
||||||
|
const { data, isPending } = useQuery({
|
||||||
|
...dashboardQueryOptions(dashboard.name, true),
|
||||||
|
enabled: preview,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/dashboards/$name"
|
||||||
|
params={{ name: dashboard.name }}
|
||||||
|
data-testid="home-dashboard-tile"
|
||||||
|
className="grid content-start gap-2 rounded-lg border border-border p-2 transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{preview && isPending ? (
|
||||||
|
<Skeleton className="aspect-video rounded-sm" />
|
||||||
|
) : data ? (
|
||||||
|
<Footprint dashboard={data} />
|
||||||
|
) : (
|
||||||
|
<div className="flex aspect-video items-center justify-center rounded-sm bg-muted">
|
||||||
|
<LayoutDashboard className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{dashboard.title || dashboard.name}
|
||||||
|
</span>
|
||||||
|
{dashboard.has_draft ? (
|
||||||
|
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
||||||
|
<span className="sr-only">Unpublished changes</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of two documents was worked on more recently.
|
||||||
|
*
|
||||||
|
* ponytail: neither `FlowSummary` nor `DashboardSummary` carries a modified
|
||||||
|
* time, so this reads the two things that come close — an unpublished edit is
|
||||||
|
* the one someone has open, and a higher version counter has been saved more
|
||||||
|
* often. An `updated_at` on both summaries is what would make it exact.
|
||||||
|
*/
|
||||||
|
export function byRecency<
|
||||||
|
T extends { name: string; has_draft?: boolean; version?: number },
|
||||||
|
>(a: T, b: T): number {
|
||||||
|
return (
|
||||||
|
Number(b.has_draft ?? false) - Number(a.has_draft ?? false) ||
|
||||||
|
(b.version ?? 0) - (a.version ?? 0) ||
|
||||||
|
a.name.localeCompare(b.name)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The dashboards, as the shapes they are, beside the flows on the home view. */
|
||||||
|
export function DashboardMosaic({
|
||||||
|
dashboards,
|
||||||
|
isPending,
|
||||||
|
}: {
|
||||||
|
dashboards: DashboardSummary[]
|
||||||
|
isPending: boolean
|
||||||
|
}) {
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 p-3 sm:grid-cols-2">
|
||||||
|
{Array.from({ length: 2 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="aspect-[4/3] rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dashboards.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
||||||
|
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
|
<LayoutDashboard className="size-5" />
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Dashboards you build show up here, as the shapes they are.
|
||||||
|
</p>
|
||||||
|
<Link to="/dashboards" className="text-sm font-medium underline">
|
||||||
|
Go to dashboards
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 p-3 sm:grid-cols-2">
|
||||||
|
{dashboards.map((dashboard, index) => (
|
||||||
|
<Tile
|
||||||
|
key={dashboard.name}
|
||||||
|
dashboard={dashboard}
|
||||||
|
preview={index < PREVIEWS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's pending screen, shown while a page's code chunk is on its way.
|
||||||
|
*
|
||||||
|
* Deliberately only the spinner. The shell around it is already painted — this
|
||||||
|
* fills the content region alone — and the page behind it draws its own
|
||||||
|
* skeletons once its code arrives, so a second guess at that layout here would
|
||||||
|
* only be a shape to correct a moment later.
|
||||||
|
*/
|
||||||
|
export function PageLoading() {
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
aria-label="Loading"
|
||||||
|
data-testid="page-loading"
|
||||||
|
className="flex min-h-64 items-center justify-center"
|
||||||
|
>
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</output>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Link, type LinkProps } from "@tanstack/react-router"
|
||||||
|
import { Check, type LucideIcon } from "lucide-react"
|
||||||
|
import { type ReactNode, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
/** How long a press has to last before it means "select this one". */
|
||||||
|
const LONG_PRESS = 450
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the listed documents are picked, and what turns picking on.
|
||||||
|
*
|
||||||
|
* Selection mode is simply "something is selected": a long press picks the
|
||||||
|
* first one and clearing the last one puts the list back to normal. So there
|
||||||
|
* is no mode to be stuck in, nothing to cancel, and no control on screen that
|
||||||
|
* exists only to turn selecting on.
|
||||||
|
*
|
||||||
|
* @param names everything currently listed. A document that is deleted, or
|
||||||
|
* filtered out by the search, cannot stay picked — deleting therefore clears
|
||||||
|
* the selection on its own, once the list comes back without it.
|
||||||
|
*/
|
||||||
|
export function useSelection(names: string[]) {
|
||||||
|
const [picked, setPicked] = useState<string[]>([])
|
||||||
|
const selected = picked.filter((name) => names.includes(name))
|
||||||
|
|
||||||
|
// Escape is the way out for anyone who started this by accident.
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected.length === 0) return
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setPicked([])
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey)
|
||||||
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
|
}, [selected.length])
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
selecting: selected.length > 0,
|
||||||
|
toggle: (name: string) =>
|
||||||
|
setPicked((current) =>
|
||||||
|
current.includes(name)
|
||||||
|
? current.filter((other) => other !== name)
|
||||||
|
: [...current, name],
|
||||||
|
),
|
||||||
|
clear: () => setPicked([]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One entry of the flows or dashboards overview.
|
||||||
|
*
|
||||||
|
* Stays a link in both modes rather than swapping element: while selecting it
|
||||||
|
* carries `role="checkbox"` instead, which is what it now behaves as. Ctrl or
|
||||||
|
* Cmd click is the same gesture for a mouse, and reaches the keyboard for
|
||||||
|
* free — a focused link answers Ctrl+Enter with a click carrying the modifier.
|
||||||
|
*/
|
||||||
|
export function OverviewCard({
|
||||||
|
link,
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
draft,
|
||||||
|
selecting,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
testId,
|
||||||
|
}: {
|
||||||
|
link: LinkProps
|
||||||
|
icon: LucideIcon
|
||||||
|
title: ReactNode
|
||||||
|
detail: ReactNode
|
||||||
|
/** Has unpublished changes. */
|
||||||
|
draft?: boolean
|
||||||
|
selecting: boolean
|
||||||
|
selected: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
testId?: string
|
||||||
|
}) {
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const held = useRef(false)
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timer.current) clearTimeout(timer.current)
|
||||||
|
timer.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...link}
|
||||||
|
data-testid={testId}
|
||||||
|
data-selected={selected || undefined}
|
||||||
|
role={selecting ? "checkbox" : undefined}
|
||||||
|
aria-checked={selecting ? selected : undefined}
|
||||||
|
className={cn(
|
||||||
|
"relative grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors",
|
||||||
|
// Both states have to stay tellable apart, so hover is the half tint.
|
||||||
|
selected ? "border-primary bg-accent" : "hover:bg-accent/50",
|
||||||
|
selecting && "select-none",
|
||||||
|
)}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
// Right-click is the browser's own gesture; leave it alone.
|
||||||
|
if (event.button !== 0) return
|
||||||
|
held.current = false
|
||||||
|
timer.current = setTimeout(() => {
|
||||||
|
held.current = true
|
||||||
|
onToggle()
|
||||||
|
}, LONG_PRESS)
|
||||||
|
}}
|
||||||
|
onPointerUp={stop}
|
||||||
|
onPointerLeave={stop}
|
||||||
|
onPointerCancel={stop}
|
||||||
|
onClick={(event) => {
|
||||||
|
// The press already picked it; the click that ends the press must not
|
||||||
|
// undo it, and must certainly not navigate.
|
||||||
|
if (held.current) {
|
||||||
|
held.current = false
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selecting || event.ctrlKey || event.metaKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
onToggle()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
// A touch hold raises this a moment after the press has been taken as
|
||||||
|
// a selection; the link preview on top of it would be a second answer
|
||||||
|
// to one gesture.
|
||||||
|
if (held.current) event.preventDefault()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
{draft ? (
|
||||||
|
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
||||||
|
<span className="sr-only">Unpublished changes</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm text-muted-foreground">{detail}</span>
|
||||||
|
|
||||||
|
{selecting ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"absolute top-3 right-3 flex size-5 items-center justify-center rounded-sm border",
|
||||||
|
selected
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border bg-card",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selected ? <Check className="size-3.5" /> : null}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import { useMutation } from "@tanstack/react-query"
|
import { useMutation } from "@tanstack/react-query"
|
||||||
import { Check, Loader2, Plus, Search } from "lucide-react"
|
import { Check, Loader2, Plus, Search, Trash2 } from "lucide-react"
|
||||||
import { motion } from "motion/react"
|
import { motion } from "motion/react"
|
||||||
import { type ReactNode, useRef, useState } from "react"
|
import { type ReactNode, useRef, useState } from "react"
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { DialogTrigger } from "@/components/ui/dialog"
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -13,6 +21,7 @@ import {
|
|||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { transitions } from "@/lib/motion"
|
import { transitions } from "@/lib/motion"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
/** The icon buttons here and in the flow dock are the same touch target. */
|
/** The icon buttons here and in the flow dock are the same touch target. */
|
||||||
const ICON = "size-11 text-muted-foreground md:size-8"
|
const ICON = "size-11 text-muted-foreground md:size-8"
|
||||||
@@ -26,7 +35,9 @@ const ICON = "size-11 text-muted-foreground md:size-8"
|
|||||||
* once it is empty and left alone, which keeps the row down to three targets.
|
* once it is empty and left alone, which keeps the row down to three targets.
|
||||||
*
|
*
|
||||||
* The create button is a `DialogTrigger`, so the page wrapping this in its own
|
* The create button is a `DialogTrigger`, so the page wrapping this in its own
|
||||||
* `Dialog` owns what asking for a name looks like.
|
* `Dialog` owns what asking for a name looks like. While anything in the list
|
||||||
|
* is selected it stands down for the trash instead: the row keeps its three
|
||||||
|
* targets, and the one primary action is whichever the list is currently for.
|
||||||
*/
|
*/
|
||||||
export function OverviewToolbar({
|
export function OverviewToolbar({
|
||||||
search,
|
search,
|
||||||
@@ -38,6 +49,8 @@ export function OverviewToolbar({
|
|||||||
draftCount,
|
draftCount,
|
||||||
publishing,
|
publishing,
|
||||||
onPublishAll,
|
onPublishAll,
|
||||||
|
selectedCount = 0,
|
||||||
|
onDeleteSelected,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
search: string
|
search: string
|
||||||
@@ -50,6 +63,9 @@ export function OverviewToolbar({
|
|||||||
draftCount: number
|
draftCount: number
|
||||||
publishing: boolean
|
publishing: boolean
|
||||||
onPublishAll: () => void
|
onPublishAll: () => void
|
||||||
|
/** How many entries are picked; above zero the list is in selection mode. */
|
||||||
|
selectedCount?: number
|
||||||
|
onDeleteSelected?: () => void
|
||||||
/** Anything this particular overview adds, drawn ahead of the shared icons. */
|
/** Anything this particular overview adds, drawn ahead of the shared icons. */
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
@@ -110,19 +126,34 @@ export function OverviewToolbar({
|
|||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
{selectedCount > 0 ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className={ICON}
|
className={cn(ICON, "text-destructive")}
|
||||||
aria-label={createLabel}
|
aria-label={`Delete ${selectedCount} selected`}
|
||||||
data-testid={createTestId}
|
data-testid="delete-selected"
|
||||||
|
onClick={onDeleteSelected}
|
||||||
>
|
>
|
||||||
<Plus />
|
<Trash2 />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
) : (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={ICON}
|
||||||
|
aria-label={createLabel}
|
||||||
|
data-testid={createTestId}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{createLabel}</TooltipContent>
|
<TooltipContent>
|
||||||
|
{selectedCount > 0 ? `Delete ${selectedCount} selected` : createLabel}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -193,3 +224,98 @@ export function usePublishAll(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the selected documents, and say how many actually went.
|
||||||
|
*
|
||||||
|
* Same one-request-each shape as `usePublishAll`: one that someone else has
|
||||||
|
* already removed, or that the server refuses, fails on its own rather than
|
||||||
|
* taking the rest of the batch with it. Nothing here clears the selection —
|
||||||
|
* the list coming back without those names is what does that.
|
||||||
|
*/
|
||||||
|
export function useDeleteSelected(
|
||||||
|
remove: (name: string) => Promise<unknown>,
|
||||||
|
noun: string,
|
||||||
|
onDone: () => void,
|
||||||
|
) {
|
||||||
|
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (names: string[]) => {
|
||||||
|
const failed: string[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
try {
|
||||||
|
await remove(name)
|
||||||
|
} catch {
|
||||||
|
failed.push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failed
|
||||||
|
},
|
||||||
|
// Some of them are gone even when others are not.
|
||||||
|
onSettled: onDone,
|
||||||
|
onSuccess: (failed, names) => {
|
||||||
|
if (failed.length)
|
||||||
|
showErrorToast(
|
||||||
|
`Deleted ${names.length - failed.length} of ${names.length}. Still there: ${failed.join(", ")}`,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
showSuccessToast(
|
||||||
|
`Deleted ${names.length} ${noun}${names.length === 1 ? "" : "s"}`,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one question a batch delete asks, however many were picked.
|
||||||
|
*
|
||||||
|
* Names the single one it is about, counts the rest: a list of twelve names in
|
||||||
|
* a dialog is read as decoration rather than as a check.
|
||||||
|
*/
|
||||||
|
export function ConfirmDelete({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
names,
|
||||||
|
noun,
|
||||||
|
pending,
|
||||||
|
onConfirm,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
names: string[]
|
||||||
|
noun: string
|
||||||
|
pending: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{names.length === 1
|
||||||
|
? `Delete "${names[0]}"?`
|
||||||
|
: `Delete these ${names.length} ${noun}s?`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{names.length === 1 ? "It goes" : "They go"} from the installation
|
||||||
|
at once. The store's git history keeps what was there, but nothing
|
||||||
|
in the app brings {names.length === 1 ? "it" : "them"} back.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Keep {names.length === 1 ? "it" : "them"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="confirm-delete"
|
||||||
|
>
|
||||||
|
Delete {names.length === 1 ? noun : `${names.length} ${noun}s`}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ function connect() {
|
|||||||
retry = RECONNECT_MIN
|
retry = RECONNECT_MIN
|
||||||
rejected = 0
|
rejected = 0
|
||||||
liveStore.setConnected(true)
|
liveStore.setConnected(true)
|
||||||
connectionStore.setOnline()
|
connectionStore.setSocketOpen(true)
|
||||||
// Whatever happened while the socket was down was missed, so nothing
|
// Whatever happened while the socket was down was missed, so nothing
|
||||||
// held in cache can be trusted to still be current.
|
// held in cache can be trusted to still be current.
|
||||||
client?.invalidateQueries()
|
client?.invalidateQueries()
|
||||||
@@ -260,6 +260,7 @@ function connect() {
|
|||||||
if (socket !== ws) return
|
if (socket !== ws) return
|
||||||
socket = null
|
socket = null
|
||||||
liveStore.setConnected(false)
|
liveStore.setConnected(false)
|
||||||
|
connectionStore.setSocketOpen(false)
|
||||||
if (watchers === 0) return
|
if (watchers === 0) return
|
||||||
if (event.code === 1008) {
|
if (event.code === 1008) {
|
||||||
rejected += 1
|
rejected += 1
|
||||||
@@ -289,6 +290,8 @@ function release(onAuthFailure?: () => void) {
|
|||||||
socket = null
|
socket = null
|
||||||
ws?.close()
|
ws?.close()
|
||||||
liveStore.setConnected(false)
|
liveStore.setConnected(false)
|
||||||
|
// Its `onclose` is dropped by the guard above, so say so here instead.
|
||||||
|
connectionStore.setSocketOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ type Connection = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let state: Connection = { offline: false, lastSeen: null }
|
let state: Connection = { offline: false, lastSeen: null }
|
||||||
|
/**
|
||||||
|
* Whether the live socket is up right now.
|
||||||
|
*
|
||||||
|
* The socket is the continuous signal; a failed request is only a lagging one.
|
||||||
|
* Latching `offline` on a single refused request is what used to leave the
|
||||||
|
* banner up for good: the socket had never dropped, so no reconnect was coming
|
||||||
|
* to clear it, and nothing else on a quiet page refetches. Reloading was the
|
||||||
|
* only way out.
|
||||||
|
*/
|
||||||
|
let socketOpen = false
|
||||||
const listeners = new Set<() => void>()
|
const listeners = new Set<() => void>()
|
||||||
|
|
||||||
function emit(next: Connection) {
|
function emit(next: Connection) {
|
||||||
@@ -28,6 +38,10 @@ function emit(next: Connection) {
|
|||||||
|
|
||||||
export const connectionStore = {
|
export const connectionStore = {
|
||||||
setOffline(lastSeen: string | number | null) {
|
setOffline(lastSeen: string | number | null) {
|
||||||
|
// The socket is up, so the installation is reachable: this was one bad
|
||||||
|
// answer rather than a lost tunnel, and the banner would have nothing to
|
||||||
|
// take it back down again.
|
||||||
|
if (socketOpen) return
|
||||||
const at =
|
const at =
|
||||||
typeof lastSeen === "string"
|
typeof lastSeen === "string"
|
||||||
? Date.parse(lastSeen)
|
? Date.parse(lastSeen)
|
||||||
@@ -39,6 +53,17 @@ export const connectionStore = {
|
|||||||
setOnline() {
|
setOnline() {
|
||||||
emit({ offline: false, lastSeen: null })
|
emit({ offline: false, lastSeen: null })
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* The live socket opened or closed.
|
||||||
|
*
|
||||||
|
* An open socket is proof the installation can be heard, so it also clears
|
||||||
|
* the banner — and since the socket reconnects on its own, that is the one
|
||||||
|
* release the offline state can always count on.
|
||||||
|
*/
|
||||||
|
setSocketOpen(open: boolean) {
|
||||||
|
socketOpen = open
|
||||||
|
if (open) emit({ offline: false, lastSeen: null })
|
||||||
|
},
|
||||||
subscribe(listener: () => void) {
|
subscribe(listener: () => void) {
|
||||||
listeners.add(listener)
|
listeners.add(listener)
|
||||||
return () => listeners.delete(listener)
|
return () => listeners.delete(listener)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { StrictMode } from "react"
|
|||||||
import ReactDOM from "react-dom/client"
|
import ReactDOM from "react-dom/client"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { ApiError, OpenAPI } from "./client"
|
import { ApiError, OpenAPI } from "./client"
|
||||||
|
import { PageLoading } from "./components/Common/Loading"
|
||||||
import { ThemeProvider } from "./components/theme-provider"
|
import { ThemeProvider } from "./components/theme-provider"
|
||||||
import { Toaster } from "./components/ui/sonner"
|
import { Toaster } from "./components/ui/sonner"
|
||||||
import "./index.css"
|
import "./index.css"
|
||||||
@@ -100,7 +101,14 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const router = createRouter({ routeTree, basepath: portal?.basePath })
|
const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
basepath: portal?.basePath,
|
||||||
|
// Routes are code-split, so going somewhere new fetches a chunk first. Under
|
||||||
|
// the threshold that arrives faster than a spinner would be worth looking at.
|
||||||
|
defaultPendingComponent: PageLoading,
|
||||||
|
defaultPendingMs: 250,
|
||||||
|
})
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: typeof router
|
router: typeof router
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||||
import { LayoutDashboard, MonitorSmartphone } from "lucide-react"
|
import { LayoutDashboard, MonitorSmartphone } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
import { DashboardsService } from "@/client"
|
import { DashboardsService } from "@/client"
|
||||||
|
import { OverviewCard, useSelection } from "@/components/Common/OverviewCard"
|
||||||
import {
|
import {
|
||||||
|
ConfirmDelete,
|
||||||
OverviewToolbar,
|
OverviewToolbar,
|
||||||
|
useDeleteSelected,
|
||||||
usePublishAll,
|
usePublishAll,
|
||||||
} from "@/components/Common/OverviewToolbar"
|
} from "@/components/Common/OverviewToolbar"
|
||||||
import { PanelsDialog } from "@/components/Dashboard/PanelsDialog"
|
import { PanelsDialog } from "@/components/Dashboard/PanelsDialog"
|
||||||
@@ -23,6 +26,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -36,7 +40,7 @@ export const Route = createFileRoute("/_layout/dashboards/")({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function Dashboards() {
|
function Dashboards() {
|
||||||
const { data } = useQuery(dashboardsQueryOptions())
|
const { data, isPending } = useQuery(dashboardsQueryOptions())
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showErrorToast } = useCustomToast()
|
const { showErrorToast } = useCustomToast()
|
||||||
@@ -44,6 +48,7 @@ function Dashboards() {
|
|||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [panelsOpen, setPanelsOpen] = useState(false)
|
const [panelsOpen, setPanelsOpen] = useState(false)
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (dashboard: string) =>
|
mutationFn: (dashboard: string) =>
|
||||||
@@ -87,6 +92,13 @@ function Dashboards() {
|
|||||||
.filter((dashboard) => dashboard.has_draft)
|
.filter((dashboard) => dashboard.has_draft)
|
||||||
.map((dashboard) => dashboard.name)
|
.map((dashboard) => dashboard.name)
|
||||||
|
|
||||||
|
const selection = useSelection(dashboards.map((dashboard) => dashboard.name))
|
||||||
|
const remove = useDeleteSelected(
|
||||||
|
(dashboard) => DashboardsService.deleteDashboard({ name: dashboard }),
|
||||||
|
"dashboard",
|
||||||
|
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
@@ -113,6 +125,8 @@ function Dashboards() {
|
|||||||
draftCount={drafts.length}
|
draftCount={drafts.length}
|
||||||
publishing={publishAll.isPending}
|
publishing={publishAll.isPending}
|
||||||
onPublishAll={() => publishAll.mutate(drafts)}
|
onPublishAll={() => publishAll.mutate(drafts)}
|
||||||
|
selectedCount={selection.selected.length}
|
||||||
|
onDeleteSelected={() => setDeleteOpen(true)}
|
||||||
>
|
>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -165,7 +179,25 @@ function Dashboards() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{dashboards.length === 0 ? (
|
<ConfirmDelete
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
names={selection.selected}
|
||||||
|
noun="dashboard"
|
||||||
|
pending={remove.isPending}
|
||||||
|
onConfirm={() => {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
remove.mutate(selection.selected)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-[5.5rem] rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : dashboards.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{needle
|
{needle
|
||||||
? "No dashboard matches that."
|
? "No dashboard matches that."
|
||||||
@@ -174,29 +206,25 @@ function Dashboards() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{dashboards.map((dashboard) => (
|
{dashboards.map((dashboard) => (
|
||||||
<Link
|
<OverviewCard
|
||||||
key={dashboard.name}
|
key={dashboard.name}
|
||||||
to="/dashboards/$name"
|
link={{
|
||||||
params={{ name: dashboard.name }}
|
to: "/dashboards/$name",
|
||||||
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
|
params: { name: dashboard.name },
|
||||||
data-testid="dashboard-card"
|
}}
|
||||||
>
|
icon={LayoutDashboard}
|
||||||
<span className="flex items-center gap-2">
|
title={dashboard.title || dashboard.name}
|
||||||
<LayoutDashboard className="size-4 text-muted-foreground" />
|
draft={dashboard.has_draft}
|
||||||
{dashboard.title || dashboard.name}
|
detail={`${dashboard.widget_count} widget${
|
||||||
{dashboard.has_draft ? (
|
dashboard.widget_count === 1 ? "" : "s"
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
} · ${dashboard.page_count} page${
|
||||||
<span className="sr-only">Unpublished changes</span>
|
dashboard.page_count === 1 ? "" : "s"
|
||||||
</span>
|
}`}
|
||||||
) : null}
|
selecting={selection.selecting}
|
||||||
</span>
|
selected={selection.selected.includes(dashboard.name)}
|
||||||
<span className="text-sm text-muted-foreground">
|
onToggle={() => selection.toggle(dashboard.name)}
|
||||||
{dashboard.widget_count} widget
|
testId="dashboard-card"
|
||||||
{dashboard.widget_count === 1 ? "" : "s"} ·{" "}
|
/>
|
||||||
{dashboard.page_count} page
|
|
||||||
{dashboard.page_count === 1 ? "" : "s"}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
|
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||||
import { Workflow } from "lucide-react"
|
import { Workflow } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
import { FlowsService } from "@/client"
|
import { FlowsService } from "@/client"
|
||||||
|
import { OverviewCard, useSelection } from "@/components/Common/OverviewCard"
|
||||||
import {
|
import {
|
||||||
|
ConfirmDelete,
|
||||||
OverviewToolbar,
|
OverviewToolbar,
|
||||||
|
useDeleteSelected,
|
||||||
usePublishAll,
|
usePublishAll,
|
||||||
} from "@/components/Common/OverviewToolbar"
|
} from "@/components/Common/OverviewToolbar"
|
||||||
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import useCustomToast from "@/hooks/useCustomToast"
|
import useCustomToast from "@/hooks/useCustomToast"
|
||||||
import { handleError } from "@/utils"
|
import { handleError } from "@/utils"
|
||||||
|
|
||||||
@@ -31,13 +35,14 @@ export const Route = createFileRoute("/_layout/flows/")({
|
|||||||
const NAME = /^[a-z][a-z0-9_]*$/
|
const NAME = /^[a-z][a-z0-9_]*$/
|
||||||
|
|
||||||
function Flows() {
|
function Flows() {
|
||||||
const { data } = useQuery(flowsQueryOptions())
|
const { data, isPending } = useQuery(flowsQueryOptions())
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { showErrorToast } = useCustomToast()
|
const { showErrorToast } = useCustomToast()
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
|
||||||
const create = useMutation({
|
const create = useMutation({
|
||||||
mutationFn: (flow: string) =>
|
mutationFn: (flow: string) =>
|
||||||
@@ -79,6 +84,13 @@ function Flows() {
|
|||||||
.filter((flow) => flow.has_draft)
|
.filter((flow) => flow.has_draft)
|
||||||
.map((flow) => flow.name)
|
.map((flow) => flow.name)
|
||||||
|
|
||||||
|
const selection = useSelection(flows.map((flow) => flow.name))
|
||||||
|
const remove = useDeleteSelected(
|
||||||
|
(flow) => FlowsService.deleteFlow({ name: flow }),
|
||||||
|
"flow",
|
||||||
|
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-6">
|
<div className="grid gap-6">
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
@@ -100,6 +112,8 @@ function Flows() {
|
|||||||
draftCount={drafts.length}
|
draftCount={drafts.length}
|
||||||
publishing={publishAll.isPending}
|
publishing={publishAll.isPending}
|
||||||
onPublishAll={() => publishAll.mutate(drafts)}
|
onPublishAll={() => publishAll.mutate(drafts)}
|
||||||
|
selectedCount={selection.selected.length}
|
||||||
|
onDeleteSelected={() => setDeleteOpen(true)}
|
||||||
/>
|
/>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<form
|
<form
|
||||||
@@ -136,7 +150,25 @@ function Flows() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{flows.length === 0 ? (
|
<ConfirmDelete
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={setDeleteOpen}
|
||||||
|
names={selection.selected}
|
||||||
|
noun="flow"
|
||||||
|
pending={remove.isPending}
|
||||||
|
onConfirm={() => {
|
||||||
|
setDeleteOpen(false)
|
||||||
|
remove.mutate(selection.selected)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isPending ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-[5.5rem] rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : flows.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{needle
|
{needle
|
||||||
? "No flow matches that."
|
? "No flow matches that."
|
||||||
@@ -145,29 +177,22 @@ function Flows() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{flows.map((flow) => (
|
{flows.map((flow) => (
|
||||||
<Link
|
<OverviewCard
|
||||||
key={flow.name}
|
key={flow.name}
|
||||||
to="/flows/$flowName"
|
link={{ to: "/flows/$flowName", params: { flowName: flow.name } }}
|
||||||
params={{ flowName: flow.name }}
|
icon={Workflow}
|
||||||
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
|
title={flow.title || flow.name}
|
||||||
data-testid="flow-card"
|
draft={flow.has_draft}
|
||||||
>
|
detail={`${flow.node_count ?? 0} node${
|
||||||
<span className="flex items-center gap-2">
|
flow.node_count === 1 ? "" : "s"
|
||||||
<Workflow className="size-4 text-muted-foreground" />
|
} · ${flow.enabled === false ? "stopped" : "running"}${
|
||||||
{flow.title || flow.name}
|
flow.error_count ? ` · ${flow.error_count} to fix` : ""
|
||||||
{flow.has_draft ? (
|
}`}
|
||||||
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
selecting={selection.selecting}
|
||||||
<span className="sr-only">Unpublished changes</span>
|
selected={selection.selected.includes(flow.name)}
|
||||||
</span>
|
onToggle={() => selection.toggle(flow.name)}
|
||||||
) : null}
|
testId="flow-card"
|
||||||
</span>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{flow.node_count ?? 0} node
|
|
||||||
{flow.node_count === 1 ? "" : "s"} ·{" "}
|
|
||||||
{flow.enabled === false ? "stopped" : "running"}
|
|
||||||
{flow.error_count ? ` · ${flow.error_count} to fix` : ""}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { AlertCircle, Workflow } from "lucide-react"
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
|
||||||
import { type FlowSummary, FlowsService } from "@/client"
|
import { type FlowSummary, FlowsService } from "@/client"
|
||||||
|
import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic"
|
||||||
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
|
import { DEFAULT_RANGE } from "@/components/Common/RangePicker"
|
||||||
|
import { dashboardsQueryOptions } from "@/components/Dashboard/queries"
|
||||||
import { BrainView } from "@/components/Flow/BrainView"
|
import { BrainView } from "@/components/Flow/BrainView"
|
||||||
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
|
||||||
import { HealthActivity } from "@/components/Health/HealthActivity"
|
import { HealthActivity } from "@/components/Health/HealthActivity"
|
||||||
@@ -30,6 +32,17 @@ export const Route = createFileRoute("/_layout/")({
|
|||||||
/** Another tab can stop a flow, and the engine can fail one on its own. */
|
/** Another tab can stop a flow, and the engine can fail one on its own. */
|
||||||
const REFRESH_INTERVAL = 10_000
|
const REFRESH_INTERVAL = 10_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How tall the two lists beside each other are allowed to get: about six flow
|
||||||
|
* rows, and whatever the mosaic fits in the same space. Past it each column
|
||||||
|
* scrolls on its own rather than pushing the health block off the screen.
|
||||||
|
*/
|
||||||
|
const LISTS = "grid max-h-96 grid-rows-[auto_minmax(0,1fr)] gap-2"
|
||||||
|
|
||||||
|
/** DESIGN-GUIDELINES.md → Typography, the canonical section header. */
|
||||||
|
const HEADER =
|
||||||
|
"text-xs font-medium uppercase tracking-[0.5px] text-muted-foreground"
|
||||||
|
|
||||||
function FlowRow({ flow }: { flow: FlowSummary }) {
|
function FlowRow({ flow }: { flow: FlowSummary }) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { showErrorToast } = useCustomToast()
|
const { showErrorToast } = useCustomToast()
|
||||||
@@ -108,8 +121,11 @@ function Home() {
|
|||||||
...flowsQueryOptions(),
|
...flowsQueryOptions(),
|
||||||
refetchInterval: REFRESH_INTERVAL,
|
refetchInterval: REFRESH_INTERVAL,
|
||||||
})
|
})
|
||||||
|
// Its own query beside the flows one, so neither list waits for the other.
|
||||||
|
const boards = useQuery(dashboardsQueryOptions())
|
||||||
|
|
||||||
const flows = data?.data ?? []
|
const flows = [...(data?.data ?? [])].sort(byRecency)
|
||||||
|
const dashboards = [...(boards.data?.data ?? [])].sort(byRecency)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one
|
// `[&>*]:min-w-0`: a grid item's automatic minimum is its content, so one
|
||||||
@@ -126,28 +142,47 @@ function Home() {
|
|||||||
rather than flow count: a flow made a minute ago has none. */}
|
rather than flow count: a flow made a minute ago has none. */}
|
||||||
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
|
{flows.some((flow) => (flow.node_count ?? 0) > 0) ? <BrainView /> : null}
|
||||||
|
|
||||||
<Card className="gap-0 py-0">
|
{/* One grid row holding both: a grid item stretches to the row, so the
|
||||||
{isPending ? (
|
two columns come out exactly as tall as each other whatever is in
|
||||||
<div className="grid gap-3 p-5">
|
them — and with one flow and one dashboard that is simply the taller
|
||||||
<Skeleton className="h-5 w-40" />
|
of the two, which is the floor the cap never goes under. */}
|
||||||
<Skeleton className="h-5 w-28" />
|
<div className="grid gap-6 [&>*]:min-w-0 lg:grid-cols-2">
|
||||||
</div>
|
<section className={LISTS}>
|
||||||
) : flows.length === 0 ? (
|
<h2 className={HEADER}>Flows</h2>
|
||||||
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
<Card className="gap-0 overflow-y-auto py-0">
|
||||||
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
{isPending ? (
|
||||||
<Workflow className="size-5" />
|
<div className="grid gap-3 p-5">
|
||||||
</span>
|
<Skeleton className="h-5 w-40" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<Skeleton className="h-5 w-28" />
|
||||||
Flows you build show up here, with what they are doing.
|
</div>
|
||||||
</p>
|
) : flows.length === 0 ? (
|
||||||
<Link to="/flows" className="text-sm font-medium underline">
|
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
||||||
Go to flows
|
<span className="flex size-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||||
</Link>
|
<Workflow className="size-5" />
|
||||||
</div>
|
</span>
|
||||||
) : (
|
<p className="text-sm text-muted-foreground">
|
||||||
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
|
Flows you build show up here, with what they are doing.
|
||||||
)}
|
</p>
|
||||||
</Card>
|
<Link to="/flows" className="text-sm font-medium underline">
|
||||||
|
Go to flows
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={LISTS}>
|
||||||
|
<h2 className={HEADER}>Dashboards</h2>
|
||||||
|
<Card className="gap-0 overflow-y-auto py-0">
|
||||||
|
<DashboardMosaic
|
||||||
|
dashboards={dashboards}
|
||||||
|
isPending={boards.isPending}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<HealthOverview range={range} onRangeChange={setRange} />
|
<HealthOverview range={range} onRangeChange={setRange} />
|
||||||
<HealthActivity range={range} />
|
<HealthActivity range={range} />
|
||||||
|
|||||||
Reference in New Issue
Block a user