Computed flow layout, and mobile written into the design
The canvas lays itself out: a layered graph, left to right on a desktop and top to bottom on a phone, with room reserved for the value each edge carries. Nodes cannot be dragged and `NodeDef.position` is gone from the document — a graph nobody can arrange is one worth keeping small, which is what keeps flows atomic. Endpoints join the same layout, so their lanes and the localStorage that remembered where they were dragged go too. Mobile, per the new Responsive section of DESIGN-GUIDELINES.md: the dock caps its width and wraps instead of running off the screen, the dashboard stacks into one column rather than shrinking a wall panel to a fifth of its size, and Home stops widening its grid track past the viewport. A Playwright project at a phone's width fails the build when a screen no longer fits. Along the way: publish is the checkmark that was already there rather than a button that appears and disappears, with discard beside it on both the flow and the dashboard; the brain reveals a neuron's name on the first tap; and the port sparklines get room to breathe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDSXaRhvqHYNevgDGmNAto
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
Settings2,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { type CSSProperties, useEffect, useRef, useState } from "react"
|
||||
@@ -27,6 +28,14 @@ import {
|
||||
} from "@/client"
|
||||
import { CanvasTitle } from "@/components/Flow/CanvasTitle"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -40,6 +49,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
import { slideUp, transitions } from "@/lib/motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { handleError } from "@/utils"
|
||||
@@ -59,7 +69,12 @@ import {
|
||||
widgetsOf,
|
||||
} from "./DashboardView"
|
||||
import { DashboardPanel, WidgetPanel } from "./panels"
|
||||
import { dashboardKeys, usePublishDashboard, useSaveDashboard } from "./queries"
|
||||
import {
|
||||
dashboardKeys,
|
||||
useDiscardDashboardDraft,
|
||||
usePublishDashboard,
|
||||
useSaveDashboard,
|
||||
} from "./queries"
|
||||
import {
|
||||
WIDGET_LABELS,
|
||||
WIDGET_SIZES,
|
||||
@@ -167,7 +182,10 @@ const same = (a: Layout, b: Layout) =>
|
||||
* A dashboard, viewed or edited, over the same dotted canvas the flows use.
|
||||
*
|
||||
* View mode never mounts the grid library: a wall panel that only displays
|
||||
* should not pay for the code that lets someone drag things around.
|
||||
* should not pay for the code that lets someone drag things around. Nor does a
|
||||
* phone — arranging is not a phone feature, and `applyLayout` below writes
|
||||
* whatever the grid reports, so a stacked layout reaching it would overwrite
|
||||
* the arrangement the panel is meant to show.
|
||||
*/
|
||||
export function DashboardEditor({
|
||||
dashboard,
|
||||
@@ -182,10 +200,15 @@ export function DashboardEditor({
|
||||
const [pageId, setPageId] = useState<string | undefined>(
|
||||
() => pagesOf(dashboard)[0]?.id,
|
||||
)
|
||||
// A phone reads the dashboard rather than arranges it, so the grid library
|
||||
// never mounts there. See DESIGN-GUIDELINES.md → Responsive.
|
||||
const stacked = useIsMobile()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const save = useSaveDashboard(dashboard.name)
|
||||
const publish = usePublishDashboard(dashboard.name)
|
||||
const discard = useDiscardDashboardDraft(dashboard.name)
|
||||
const [discardOpen, setDiscardOpen] = useState(false)
|
||||
const { showErrorToast } = useCustomToast()
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// The save that is already on its way, so a publish waits for it instead of
|
||||
@@ -338,6 +361,27 @@ export function DashboardEditor({
|
||||
})
|
||||
}
|
||||
|
||||
/** A widget that can be picked to open its settings. */
|
||||
const pickable = (widget: WidgetDef, grip = false) => (
|
||||
<WidgetFrame
|
||||
title={widget.title}
|
||||
issue={widgetIssue(widget)}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
widget.id === selected && "ring-2 ring-primary",
|
||||
)}
|
||||
grip={grip}
|
||||
onClick={(event) => {
|
||||
if (!(event.target as Element).closest(INTERACTIVE)) {
|
||||
setSettingsOpen(false)
|
||||
setSelected(widget.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||
</WidgetFrame>
|
||||
)
|
||||
|
||||
const body = !page ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This dashboard has no pages yet.
|
||||
@@ -346,6 +390,17 @@ export function DashboardEditor({
|
||||
<p className="text-sm text-muted-foreground" data-testid="dashboard-empty">
|
||||
Nothing on this page yet. Add a widget from the bar below.
|
||||
</p>
|
||||
) : stacked ? (
|
||||
// One column at the viewport's width. Edit mode still picks a widget and
|
||||
// opens its settings; only the arrangement is missing.
|
||||
<div className="h-full overflow-y-auto">
|
||||
<DashboardView
|
||||
dashboard={draft}
|
||||
pageId={page.id}
|
||||
stacked
|
||||
renderWidget={edit ? pickable : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CanvasSurface dashboard={draft} dots={edit}>
|
||||
{(scale) =>
|
||||
@@ -372,25 +427,7 @@ export function DashboardEditor({
|
||||
resizeConfig={{ handles: ["e", "s", "se"] }}
|
||||
>
|
||||
{widgets.map((widget) => (
|
||||
<div key={widget.id}>
|
||||
<WidgetFrame
|
||||
title={widget.title}
|
||||
issue={widgetIssue(widget)}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
widget.id === selected && "ring-2 ring-primary",
|
||||
)}
|
||||
grip
|
||||
onClick={(event) => {
|
||||
if (!(event.target as Element).closest(INTERACTIVE)) {
|
||||
setSettingsOpen(false)
|
||||
setSelected(widget.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<WidgetBody widget={widget} dashboard={draft.name} />
|
||||
</WidgetFrame>
|
||||
</div>
|
||||
<div key={widget.id}>{pickable(widget, true)}</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
)
|
||||
@@ -439,7 +476,9 @@ export function DashboardEditor({
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
transition={transitions.emphasized}
|
||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||
// Capped and wrapping, like the flow dock; see DESIGN-GUIDELINES.md
|
||||
// → Responsive.
|
||||
className="pointer-events-auto absolute bottom-4 left-1/2 z-10 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center justify-center gap-1 rounded-full border border-border bg-card/80 px-1.5 py-1 shadow-e2 backdrop-blur-md pb-[max(0.25rem,env(safe-area-inset-bottom))]"
|
||||
>
|
||||
{edit ? (
|
||||
<>
|
||||
@@ -494,39 +533,66 @@ export function DashboardEditor({
|
||||
<TooltipContent>Dashboard settings</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Only while there is something to throw away. */}
|
||||
{hasDraft ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => setDiscardOpen(true)}
|
||||
aria-label="Discard the unpublished changes"
|
||||
data-testid="discard-dashboard"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Discard the unpublished changes
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{/* Saved state and publish are one control, as in the flow dock:
|
||||
the glyph stays put and simply stops being pressable. */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{save.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 text-muted-foreground md:size-8"
|
||||
onClick={() => void publishDashboard()}
|
||||
disabled={
|
||||
!hasDraft || publish.isPending || save.isPending
|
||||
}
|
||||
aria-label="Publish this dashboard"
|
||||
data-testid="publish-dashboard"
|
||||
>
|
||||
{save.isPending || publish.isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{save.isPending
|
||||
? "Saving"
|
||||
: hasDraft
|
||||
? "Saved — publish to put it on the panels"
|
||||
: "All changes saved"}
|
||||
{publish.isPending
|
||||
? "Publishing"
|
||||
: save.isPending
|
||||
? "Saving"
|
||||
: hasDraft
|
||||
? "Saved — publish to put it on the panels"
|
||||
: "All changes saved"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{hasDraft ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-11 shrink-0 rounded-full md:h-8"
|
||||
onClick={() => void publishDashboard()}
|
||||
disabled={publish.isPending}
|
||||
data-testid="publish-dashboard"
|
||||
>
|
||||
{publish.isPending ? "Publishing…" : "Publish"}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mx-0.5 !h-5 hidden md:block"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -590,6 +656,38 @@ export function DashboardEditor({
|
||||
onDelete={() => remove.mutate()}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
/>
|
||||
|
||||
<Dialog open={discardOpen} onOpenChange={setDiscardOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Discard the unpublished changes?</DialogTitle>
|
||||
<DialogDescription>
|
||||
The dashboard goes back to what the panels are showing. What
|
||||
you edited since is dropped.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDiscardOpen(false)}>
|
||||
Keep editing
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={discard.isPending}
|
||||
onClick={() => {
|
||||
setDiscardOpen(false)
|
||||
// Leaving edit mode remounts the editor, which is how the
|
||||
// published document replaces the working copy.
|
||||
discard.mutate(undefined, {
|
||||
onSuccess: () => setEdit(false),
|
||||
})
|
||||
}}
|
||||
data-testid="confirm-discard-dashboard"
|
||||
>
|
||||
Discard changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -169,6 +169,7 @@ export function SectionGrid({
|
||||
dashboard,
|
||||
columns = DEFAULT_COLUMNS,
|
||||
renderWidget,
|
||||
stacked,
|
||||
className,
|
||||
}: {
|
||||
section: SectionDef_Output
|
||||
@@ -176,9 +177,20 @@ export function SectionGrid({
|
||||
dashboard: string
|
||||
columns?: number
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
/** One column at the viewport's width, for a phone. */
|
||||
stacked?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const widgets = widgetsOf(section)
|
||||
const all = widgetsOf(section)
|
||||
// Stacked, the arrangement becomes a reading order, so it follows the rows
|
||||
// the panel shows rather than the order widgets happened to be added in.
|
||||
const widgets = stacked
|
||||
? [...all].sort((a, b) => {
|
||||
const left = placement(a)
|
||||
const right = placement(b)
|
||||
return (left.y ?? 0) - (right.y ?? 0) || (left.x ?? 0) - (right.x ?? 0)
|
||||
})
|
||||
: all
|
||||
return (
|
||||
<section className="grid gap-3">
|
||||
{section.title ? (
|
||||
@@ -187,7 +199,7 @@ export function SectionGrid({
|
||||
</h2>
|
||||
) : null}
|
||||
<div
|
||||
className={cn("widget-grid", className)}
|
||||
className={cn("widget-grid", stacked && "widget-stacked", className)}
|
||||
data-placed={isPlaced(widgets) || undefined}
|
||||
style={{ "--widget-cols": columns } as React.CSSProperties}
|
||||
>
|
||||
@@ -215,10 +227,13 @@ export function DashboardView({
|
||||
dashboard,
|
||||
pageId,
|
||||
renderWidget,
|
||||
stacked,
|
||||
}: {
|
||||
dashboard: Dashboard
|
||||
pageId?: string
|
||||
renderWidget?: (widget: WidgetDef) => React.ReactNode
|
||||
/** One column at the viewport's width, for a phone. */
|
||||
stacked?: boolean
|
||||
}) {
|
||||
const pages = pagesOf(dashboard)
|
||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||
@@ -254,6 +269,7 @@ export function DashboardView({
|
||||
dashboard={dashboard.name}
|
||||
columns={columnsOf(dashboard)}
|
||||
renderWidget={renderWidget}
|
||||
stacked={stacked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -22,9 +22,10 @@
|
||||
|
||||
/*
|
||||
* View mode's grid. Column count is per dashboard (`--widget-cols`), so a wall
|
||||
* panel can be matched to its own width. There is no responsive fallback: the
|
||||
* grid lives on a canvas of the panel's own pixel size, which is scaled to the
|
||||
* viewport rather than reflowed into it.
|
||||
* panel can be matched to its own width. It does not reflow: the grid lives on
|
||||
* a canvas of the panel's own pixel size, which is scaled to the viewport.
|
||||
* A phone gets `.widget-stacked` below instead, which is a different surface
|
||||
* rather than a narrower version of this one.
|
||||
*/
|
||||
.widget-grid {
|
||||
display: grid;
|
||||
@@ -46,6 +47,25 @@
|
||||
grid-row: var(--y) / span var(--h);
|
||||
}
|
||||
|
||||
/*
|
||||
* One column, in reading order, at the viewport's own width — what a phone
|
||||
* gets instead of a wall panel shrunk to 18%. Arranging is not a phone
|
||||
* feature, so the stored x/w are dropped and only the height a widget asked
|
||||
* for survives: a chart still needs its room, a stat still does not.
|
||||
* See DESIGN-GUIDELINES.md → Responsive.
|
||||
*/
|
||||
.widget-grid.widget-stacked,
|
||||
.widget-grid.widget-stacked[data-placed] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.widget-grid.widget-stacked .widget-cell {
|
||||
grid-column: auto;
|
||||
grid-row: auto;
|
||||
height: calc(var(--h) * 5rem + (var(--h) - 1) * 0.75rem);
|
||||
}
|
||||
|
||||
/*
|
||||
* uPlot, routed through the tokens. Its own legend is the hover readout as
|
||||
* well — the value each line carried at the cursor — so it is styled as chart
|
||||
|
||||
@@ -75,6 +75,19 @@ export function usePublishDashboard(name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Throw the unpublished edit away; the panels keep showing what they had. */
|
||||
export function useDiscardDashboardDraft(name: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: () => DashboardsService.discardDashboardDraft({ name }),
|
||||
onSuccess: (published) => {
|
||||
queryClient.setQueryData(dashboardKeys.detail(name, true), published)
|
||||
queryClient.setQueryData(dashboardKeys.detail(name), published)
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** What an input widget does: put a value into the graph.
|
||||
*
|
||||
* The widget names itself so the flow canvas can show the value arriving from
|
||||
|
||||
@@ -201,6 +201,7 @@ export function WidgetFrame({
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: see above.
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: see above.
|
||||
<div
|
||||
data-testid="widget-frame"
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-2 overflow-hidden rounded-lg border border-border bg-card p-4 shadow-e1",
|
||||
className,
|
||||
|
||||
Reference in New Issue
Block a user