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:
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1">
|
||||
@@ -113,6 +125,8 @@ function Dashboards() {
|
||||
draftCount={drafts.length}
|
||||
publishing={publishAll.isPending}
|
||||
onPublishAll={() => publishAll.mutate(drafts)}
|
||||
selectedCount={selection.selected.length}
|
||||
onDeleteSelected={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -165,7 +179,25 @@ function Dashboards() {
|
||||
</DialogContent>
|
||||
</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">
|
||||
{needle
|
||||
? "No dashboard matches that."
|
||||
@@ -174,29 +206,25 @@ function Dashboards() {
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{dashboards.map((dashboard) => (
|
||||
<Link
|
||||
<OverviewCard
|
||||
key={dashboard.name}
|
||||
to="/dashboards/$name"
|
||||
params={{ name: dashboard.name }}
|
||||
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
|
||||
data-testid="dashboard-card"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<LayoutDashboard className="size-4 text-muted-foreground" />
|
||||
{dashboard.title || dashboard.name}
|
||||
{dashboard.has_draft ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
||||
<span className="sr-only">Unpublished changes</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{dashboard.widget_count} widget
|
||||
{dashboard.widget_count === 1 ? "" : "s"} ·{" "}
|
||||
{dashboard.page_count} page
|
||||
{dashboard.page_count === 1 ? "" : "s"}
|
||||
</span>
|
||||
</Link>
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1">
|
||||
@@ -100,6 +112,8 @@ function Flows() {
|
||||
draftCount={drafts.length}
|
||||
publishing={publishAll.isPending}
|
||||
onPublishAll={() => publishAll.mutate(drafts)}
|
||||
selectedCount={selection.selected.length}
|
||||
onDeleteSelected={() => setDeleteOpen(true)}
|
||||
/>
|
||||
<DialogContent>
|
||||
<form
|
||||
@@ -136,7 +150,25 @@ function Flows() {
|
||||
</DialogContent>
|
||||
</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">
|
||||
{needle
|
||||
? "No flow matches that."
|
||||
@@ -145,29 +177,22 @@ function Flows() {
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<Link
|
||||
<OverviewCard
|
||||
key={flow.name}
|
||||
to="/flows/$flowName"
|
||||
params={{ flowName: flow.name }}
|
||||
className="grid gap-1 rounded-lg border border-border bg-card p-4 shadow-e1 transition-colors hover:bg-accent/50"
|
||||
data-testid="flow-card"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Workflow className="size-4 text-muted-foreground" />
|
||||
{flow.title || flow.name}
|
||||
{flow.has_draft ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-primary">
|
||||
<span className="sr-only">Unpublished changes</span>
|
||||
</span>
|
||||
) : null}
|
||||
</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>
|
||||
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"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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) ? <BrainView /> : null}
|
||||
|
||||
<Card className="gap-0 py-0">
|
||||
{isPending ? (
|
||||
<div className="grid gap-3 p-5">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<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">
|
||||
<Workflow className="size-5" />
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Flows you build show up here, with what they are doing.
|
||||
</p>
|
||||
<Link to="/flows" className="text-sm font-medium underline">
|
||||
Go to flows
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
flows.map((flow) => <FlowRow key={flow.name} flow={flow} />)
|
||||
)}
|
||||
</Card>
|
||||
{/* 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. */}
|
||||
<div className="grid gap-6 [&>*]:min-w-0 lg:grid-cols-2">
|
||||
<section className={LISTS}>
|
||||
<h2 className={HEADER}>Flows</h2>
|
||||
<Card className="gap-0 overflow-y-auto py-0">
|
||||
{isPending ? (
|
||||
<div className="grid gap-3 p-5">
|
||||
<Skeleton className="h-5 w-40" />
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<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">
|
||||
<Workflow className="size-5" />
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Flows you build show up here, with what they are doing.
|
||||
</p>
|
||||
<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} />
|
||||
<HealthActivity range={range} />
|
||||
|
||||
Reference in New Issue
Block a user