Overviews: icon toolbar, dashboard drafts, publish all
Both overviews carried the same toolbar twice, left-aligned, with a search
field permanently taking a row of width. One `OverviewToolbar` now serves
them: the search folds into an icon and expands again on click (Escape puts
it away and hands focus back), create is a `+`, and everything sits right of
the page. Each page keeps its own create dialog — the toolbar only renders
the trigger — so the testids the runtime spec and the capture script drive
stayed where they were.
Dashboards get the flow store's draft/publish split. The editor autosaves
`dashboard.draft.json` beside `dashboard.json`; `/view/{name}`, `bindings_for`
and `history_requirements` keep reading the published file, so a wall panel
sees an edit only once someone publishes it. `POST /dashboards/{name}/publish`
and `/discard` mirror the flow routes down to the version precondition and the
409, `GET /dashboards/{name}?draft=true` is what the editor asks for, and the
dock grows the same Publish button — which flushes a queued save first, so an
autosave in flight is not published around. Creating a dashboard still writes
the published file directly: an empty document on a panel is harmless, and it
keeps the store free of a never-published case.
"Publish all" is a checkmark in the toolbar, live only when something actually
has `has_draft`. A summary carries no version and publish needs the one it is
based on, so each document's detail is read immediately before its publish —
honest against a stale list, and no version-less backend path to maintain.
Failures are counted rather than swallowed: three of five fails says so and
names the three.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XC2jX6Hdj7pxGGKzBTrbqB
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { Check, Loader2, Plus, Search } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { transitions } from "@/lib/motion"
|
||||
|
||||
/** The icon buttons here and in the flow dock are the same touch target. */
|
||||
const ICON = "size-11 text-muted-foreground md:size-8"
|
||||
|
||||
/**
|
||||
* The bar over the flows and dashboards lists: find one, publish what is
|
||||
* unpublished, or start a new one.
|
||||
*
|
||||
* Everything is an icon, right-aligned, so the list itself is what the page
|
||||
* shows. The search field is folded away until it is asked for and folds back
|
||||
* 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.
|
||||
*/
|
||||
export function OverviewToolbar({
|
||||
search,
|
||||
onSearch,
|
||||
searchLabel,
|
||||
searchTestId,
|
||||
createLabel,
|
||||
createTestId,
|
||||
draftCount,
|
||||
publishing,
|
||||
onPublishAll,
|
||||
}: {
|
||||
search: string
|
||||
onSearch: (value: string) => void
|
||||
searchLabel: string
|
||||
searchTestId: string
|
||||
createLabel: string
|
||||
createTestId: string
|
||||
/** How many of the listed documents have unpublished changes. */
|
||||
draftCount: number
|
||||
publishing: boolean
|
||||
onPublishAll: () => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const trigger = useRef<HTMLButtonElement>(null)
|
||||
|
||||
/** Escape puts the field away and hands focus back to the icon it came from. */
|
||||
const collapse = () => {
|
||||
onSearch("")
|
||||
setOpen(false)
|
||||
trigger.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{open ? (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: "16rem", opacity: 1 }}
|
||||
transition={transitions.emphasized}
|
||||
// Shrinks rather than pushing the buttons off a narrow screen.
|
||||
className="min-w-0 overflow-hidden"
|
||||
>
|
||||
<Input
|
||||
// The field exists because it was just asked for, so it takes focus.
|
||||
autoFocus
|
||||
value={search}
|
||||
placeholder={searchLabel}
|
||||
aria-label={searchLabel}
|
||||
data-testid={searchTestId}
|
||||
onChange={(event) => onSearch(event.target.value)}
|
||||
onBlur={() => {
|
||||
if (!search.trim()) setOpen(false)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") collapse()
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
ref={trigger}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON}
|
||||
aria-label={searchLabel}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Search />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{searchLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* A disabled button gets no pointer events, so the tooltip that
|
||||
explains why it is disabled needs a wrapper to hang on. */}
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON}
|
||||
disabled={draftCount === 0 || publishing}
|
||||
onClick={onPublishAll}
|
||||
aria-label="Publish all changes"
|
||||
data-testid="publish-all"
|
||||
>
|
||||
{publishing ? <Loader2 className="animate-spin" /> : <Check />}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{draftCount === 0
|
||||
? "Nothing unpublished"
|
||||
: `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON}
|
||||
aria-label={createLabel}
|
||||
data-testid={createTestId}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{createLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish several documents in turn, and say how many actually made it.
|
||||
*
|
||||
* Each one is its own request with its own version precondition, so one that
|
||||
* someone else has moved past fails on its own rather than taking the batch
|
||||
* with it — and the toast names the ones still unpublished instead of
|
||||
* reporting a success that did not happen.
|
||||
*/
|
||||
export function usePublishAll(
|
||||
publish: (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 publish(name)
|
||||
} catch {
|
||||
failed.push(name)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
},
|
||||
// Some of them may have landed even when others did not.
|
||||
onSettled: onDone,
|
||||
onSuccess: (failed, names) => {
|
||||
if (failed.length)
|
||||
showErrorToast(
|
||||
`Published ${names.length - failed.length} of ${names.length}. Still unpublished: ${failed.join(", ")}`,
|
||||
)
|
||||
else
|
||||
showSuccessToast(
|
||||
`Published ${names.length} ${noun}${names.length === 1 ? "" : "s"}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user