From cec31ba85375359a03c9f7a063862ce3cf981b6d Mon Sep 17 00:00:00 2001 From: stroblme Date: Sat, 22 Aug 2026 12:02:00 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu --- docs/interface/index.md | 21 +- frontend/index.html | 32 ++- .../components/Common/ConnectionBanner.tsx | 11 +- .../src/components/Common/DashboardMosaic.tsx | 263 ++++++++++++++++++ frontend/src/components/Common/Loading.tsx | 21 ++ .../src/components/Common/OverviewCard.tsx | 158 +++++++++++ .../src/components/Common/OverviewToolbar.tsx | 146 +++++++++- frontend/src/components/Flow/useFlowSocket.ts | 5 +- frontend/src/lib/connectionStore.ts | 25 ++ frontend/src/main.tsx | 10 +- .../src/routes/_layout/dashboards/index.tsx | 78 ++++-- frontend/src/routes/_layout/flows/index.tsx | 75 +++-- frontend/src/routes/_layout/index.tsx | 81 ++++-- 13 files changed, 837 insertions(+), 89 deletions(-) create mode 100644 frontend/src/components/Common/DashboardMosaic.tsx create mode 100644 frontend/src/components/Common/Loading.tsx create mode 100644 frontend/src/components/Common/OverviewCard.tsx diff --git a/docs/interface/index.md b/docs/interface/index.md index df99f09..8c25987 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -26,7 +26,7 @@ phone the sidebar collapses to a sheet. ## Home -The one screen you leave open. Three things share it. +The one screen you leave open. Four things share it. ### 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. +### 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 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). +### 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 - [The flow editor](flow-editor.md) — the canvas, the code editor, running and diff --git a/frontend/index.html b/frontend/index.html index 4299a86..5e1f483 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,9 +17,39 @@ } catch (_e) { } })() + + -
+
+
+
diff --git a/frontend/src/components/Common/ConnectionBanner.tsx b/frontend/src/components/Common/ConnectionBanner.tsx index 1856f03..82475c9 100644 --- a/frontend/src/components/Common/ConnectionBanner.tsx +++ b/frontend/src/components/Common/ConnectionBanner.tsx @@ -1,6 +1,7 @@ import { WifiOff } from "lucide-react" import { AnimatePresence, motion } from "motion/react" import { useSyncExternalStore } from "react" +import { createPortal } from "react-dom" import { ago } from "@/components/Health/queries" 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 — * stale readings with a timestamp are more use than an empty page, which is * 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() { const connection = useSyncExternalStore( @@ -24,7 +30,7 @@ export function ConnectionBanner() { ) if (!isPortal()) return null - return ( + return createPortal( {connection.offline && ( )} - + , + document.body, ) } diff --git a/frontend/src/components/Common/DashboardMosaic.tsx b/frontend/src/components/Common/DashboardMosaic.tsx new file mode 100644 index 0000000..70a1c63 --- /dev/null +++ b/frontend/src/components/Common/DashboardMosaic.tsx @@ -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 + 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 ( +
+ +
+ ) + } + + return ( +
+ {blocks.map((block) => { + const width = Math.min(columns, block.w) + return ( + + ) + })} +
+ ) +} + +/** + * 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 ( + + {preview && isPending ? ( + + ) : data ? ( + + ) : ( +
+ +
+ )} + + + {dashboard.title || dashboard.name} + + {dashboard.has_draft ? ( + + Unpublished changes + + ) : null} + + + ) +} + +/** + * 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 ( +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+ ) + } + + if (dashboards.length === 0) { + return ( +
+ + + +

+ Dashboards you build show up here, as the shapes they are. +

+ + Go to dashboards + +
+ ) + } + + return ( +
+ {dashboards.map((dashboard, index) => ( + + ))} +
+ ) +} diff --git a/frontend/src/components/Common/Loading.tsx b/frontend/src/components/Common/Loading.tsx new file mode 100644 index 0000000..004333e --- /dev/null +++ b/frontend/src/components/Common/Loading.tsx @@ -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 ( + + + + ) +} diff --git a/frontend/src/components/Common/OverviewCard.tsx b/frontend/src/components/Common/OverviewCard.tsx new file mode 100644 index 0000000..0fdad82 --- /dev/null +++ b/frontend/src/components/Common/OverviewCard.tsx @@ -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([]) + 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 | null>(null) + const held = useRef(false) + + const stop = () => { + if (timer.current) clearTimeout(timer.current) + timer.current = null + } + + return ( + { + // 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() + }} + > + + + {title} + {draft ? ( + + Unpublished changes + + ) : null} + + {detail} + + {selecting ? ( + + {selected ? : null} + + ) : null} + + ) +} diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx index 287d93f..593b5ff 100644 --- a/frontend/src/components/Common/OverviewToolbar.tsx +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -1,10 +1,18 @@ 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 { type ReactNode, useRef, useState } from "react" 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 { Tooltip, @@ -13,6 +21,7 @@ import { } from "@/components/ui/tooltip" import useCustomToast from "@/hooks/useCustomToast" import { transitions } from "@/lib/motion" +import { cn } from "@/lib/utils" /** The icon buttons here and in the flow dock are the same touch target. */ 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. * * 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({ search, @@ -38,6 +49,8 @@ export function OverviewToolbar({ draftCount, publishing, onPublishAll, + selectedCount = 0, + onDeleteSelected, children, }: { search: string @@ -50,6 +63,9 @@ export function OverviewToolbar({ draftCount: number publishing: boolean 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. */ children?: ReactNode }) { @@ -110,19 +126,34 @@ export function OverviewToolbar({ - + {selectedCount > 0 ? ( - + ) : ( + + + + )} - {createLabel} + + {selectedCount > 0 ? `Delete ${selectedCount} selected` : createLabel} + @@ -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, + 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 ( + + + + + {names.length === 1 + ? `Delete "${names[0]}"?` + : `Delete these ${names.length} ${noun}s?`} + + + {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. + + + + + + + + + ) +} diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index e229128..05681b7 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -144,7 +144,7 @@ function connect() { retry = RECONNECT_MIN rejected = 0 liveStore.setConnected(true) - connectionStore.setOnline() + connectionStore.setSocketOpen(true) // Whatever happened while the socket was down was missed, so nothing // held in cache can be trusted to still be current. client?.invalidateQueries() @@ -260,6 +260,7 @@ function connect() { if (socket !== ws) return socket = null liveStore.setConnected(false) + connectionStore.setSocketOpen(false) if (watchers === 0) return if (event.code === 1008) { rejected += 1 @@ -289,6 +290,8 @@ function release(onAuthFailure?: () => void) { socket = null ws?.close() liveStore.setConnected(false) + // Its `onclose` is dropped by the guard above, so say so here instead. + connectionStore.setSocketOpen(false) } /** diff --git a/frontend/src/lib/connectionStore.ts b/frontend/src/lib/connectionStore.ts index 6c441ee..532f17a 100644 --- a/frontend/src/lib/connectionStore.ts +++ b/frontend/src/lib/connectionStore.ts @@ -16,6 +16,16 @@ type Connection = { } 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>() function emit(next: Connection) { @@ -28,6 +38,10 @@ function emit(next: Connection) { export const connectionStore = { 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 = typeof lastSeen === "string" ? Date.parse(lastSeen) @@ -39,6 +53,17 @@ export const connectionStore = { setOnline() { 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) { listeners.add(listener) return () => listeners.delete(listener) diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d1a5cca..1e5ddf9 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -10,6 +10,7 @@ import { StrictMode } from "react" import ReactDOM from "react-dom/client" import { toast } from "sonner" import { ApiError, OpenAPI } from "./client" +import { PageLoading } from "./components/Common/Loading" import { ThemeProvider } from "./components/theme-provider" import { Toaster } from "./components/ui/sonner" 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" { interface Register { router: typeof router diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index ffae504..ddc462c 100644 --- a/frontend/src/routes/_layout/dashboards/index.tsx +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -1,11 +1,14 @@ 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 { useState } from "react" import { DashboardsService } from "@/client" +import { OverviewCard, useSelection } from "@/components/Common/OverviewCard" import { + ConfirmDelete, OverviewToolbar, + useDeleteSelected, usePublishAll, } from "@/components/Common/OverviewToolbar" import { PanelsDialog } from "@/components/Dashboard/PanelsDialog" @@ -23,6 +26,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" import { Tooltip, TooltipContent, @@ -36,7 +40,7 @@ export const Route = createFileRoute("/_layout/dashboards/")({ }) function Dashboards() { - const { data } = useQuery(dashboardsQueryOptions()) + const { data, isPending } = useQuery(dashboardsQueryOptions()) const queryClient = useQueryClient() const navigate = useNavigate() const { showErrorToast } = useCustomToast() @@ -44,6 +48,7 @@ function Dashboards() { const [search, setSearch] = useState("") const [dialogOpen, setDialogOpen] = useState(false) const [panelsOpen, setPanelsOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) const create = useMutation({ mutationFn: (dashboard: string) => @@ -87,6 +92,13 @@ function Dashboards() { .filter((dashboard) => dashboard.has_draft) .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 (
@@ -113,6 +125,8 @@ function Dashboards() { draftCount={drafts.length} publishing={publishAll.isPending} onPublishAll={() => publishAll.mutate(drafts)} + selectedCount={selection.selected.length} + onDeleteSelected={() => setDeleteOpen(true)} > @@ -165,7 +179,25 @@ function Dashboards() { - {dashboards.length === 0 ? ( + { + setDeleteOpen(false) + remove.mutate(selection.selected) + }} + /> + + {isPending ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : dashboards.length === 0 ? (

{needle ? "No dashboard matches that." @@ -174,29 +206,25 @@ function Dashboards() { ) : (

{dashboards.map((dashboard) => ( - - - - {dashboard.title || dashboard.name} - {dashboard.has_draft ? ( - - Unpublished changes - - ) : null} - - - {dashboard.widget_count} widget - {dashboard.widget_count === 1 ? "" : "s"} ·{" "} - {dashboard.page_count} page - {dashboard.page_count === 1 ? "" : "s"} - - + link={{ + to: "/dashboards/$name", + params: { name: dashboard.name }, + }} + icon={LayoutDashboard} + title={dashboard.title || dashboard.name} + draft={dashboard.has_draft} + detail={`${dashboard.widget_count} widget${ + dashboard.widget_count === 1 ? "" : "s" + } · ${dashboard.page_count} page${ + dashboard.page_count === 1 ? "" : "s" + }`} + selecting={selection.selecting} + selected={selection.selected.includes(dashboard.name)} + onToggle={() => selection.toggle(dashboard.name)} + testId="dashboard-card" + /> ))}
)} diff --git a/frontend/src/routes/_layout/flows/index.tsx b/frontend/src/routes/_layout/flows/index.tsx index 449ce80..d6d590c 100644 --- a/frontend/src/routes/_layout/flows/index.tsx +++ b/frontend/src/routes/_layout/flows/index.tsx @@ -1,11 +1,14 @@ 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 { useState } from "react" import { FlowsService } from "@/client" +import { OverviewCard, useSelection } from "@/components/Common/OverviewCard" import { + ConfirmDelete, OverviewToolbar, + useDeleteSelected, usePublishAll, } from "@/components/Common/OverviewToolbar" import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries" @@ -19,6 +22,7 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" import useCustomToast from "@/hooks/useCustomToast" import { handleError } from "@/utils" @@ -31,13 +35,14 @@ export const Route = createFileRoute("/_layout/flows/")({ const NAME = /^[a-z][a-z0-9_]*$/ function Flows() { - const { data } = useQuery(flowsQueryOptions()) + const { data, isPending } = useQuery(flowsQueryOptions()) const queryClient = useQueryClient() const navigate = useNavigate() const { showErrorToast } = useCustomToast() const [name, setName] = useState("") const [search, setSearch] = useState("") const [dialogOpen, setDialogOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) const create = useMutation({ mutationFn: (flow: string) => @@ -79,6 +84,13 @@ function Flows() { .filter((flow) => flow.has_draft) .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 (
@@ -100,6 +112,8 @@ function Flows() { draftCount={drafts.length} publishing={publishAll.isPending} onPublishAll={() => publishAll.mutate(drafts)} + selectedCount={selection.selected.length} + onDeleteSelected={() => setDeleteOpen(true)} />
- {flows.length === 0 ? ( + { + setDeleteOpen(false) + remove.mutate(selection.selected) + }} + /> + + {isPending ? ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ ) : flows.length === 0 ? (

{needle ? "No flow matches that." @@ -145,29 +177,22 @@ function Flows() { ) : (

{flows.map((flow) => ( - - - - {flow.title || flow.name} - {flow.has_draft ? ( - - Unpublished changes - - ) : null} - - - {flow.node_count ?? 0} node - {flow.node_count === 1 ? "" : "s"} ·{" "} - {flow.enabled === false ? "stopped" : "running"} - {flow.error_count ? ` · ${flow.error_count} to fix` : ""} - - + link={{ to: "/flows/$flowName", params: { flowName: flow.name } }} + icon={Workflow} + title={flow.title || flow.name} + draft={flow.has_draft} + detail={`${flow.node_count ?? 0} node${ + flow.node_count === 1 ? "" : "s" + } · ${flow.enabled === false ? "stopped" : "running"}${ + flow.error_count ? ` · ${flow.error_count} to fix` : "" + }`} + selecting={selection.selecting} + selected={selection.selected.includes(flow.name)} + onToggle={() => selection.toggle(flow.name)} + testId="flow-card" + /> ))}
)} diff --git a/frontend/src/routes/_layout/index.tsx b/frontend/src/routes/_layout/index.tsx index 8390330..4a9fc4a 100644 --- a/frontend/src/routes/_layout/index.tsx +++ b/frontend/src/routes/_layout/index.tsx @@ -4,7 +4,9 @@ import { AlertCircle, Workflow } from "lucide-react" import { useState } from "react" import { type FlowSummary, FlowsService } from "@/client" +import { byRecency, DashboardMosaic } from "@/components/Common/DashboardMosaic" import { DEFAULT_RANGE } from "@/components/Common/RangePicker" +import { dashboardsQueryOptions } from "@/components/Dashboard/queries" import { BrainView } from "@/components/Flow/BrainView" import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries" 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. */ 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 }) { const queryClient = useQueryClient() const { showErrorToast } = useCustomToast() @@ -108,8 +121,11 @@ function Home() { ...flowsQueryOptions(), 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 ( // `[&>*]: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. */} {flows.some((flow) => (flow.node_count ?? 0) > 0) ? : null} - - {isPending ? ( -
- - -
- ) : flows.length === 0 ? ( -
- - - -

- Flows you build show up here, with what they are doing. -

- - Go to flows - -
- ) : ( - flows.map((flow) => ) - )} -
+ {/* One grid row holding both: a grid item stretches to the row, so the + two columns come out exactly as tall as each other whatever is in + them — and with one flow and one dashboard that is simply the taller + of the two, which is the floor the cap never goes under. */} +
+
+

Flows

+ + {isPending ? ( +
+ + +
+ ) : flows.length === 0 ? ( +
+ + + +

+ Flows you build show up here, with what they are doing. +

+ + Go to flows + +
+ ) : ( + flows.map((flow) => ) + )} +
+
+ +
+

Dashboards

+ + + +
+