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:
@@ -59,7 +59,7 @@ import {
|
||||
widgetsOf,
|
||||
} from "./DashboardView"
|
||||
import { DashboardPanel, WidgetPanel } from "./panels"
|
||||
import { dashboardKeys, useSaveDashboard } from "./queries"
|
||||
import { dashboardKeys, usePublishDashboard, useSaveDashboard } from "./queries"
|
||||
import {
|
||||
WIDGET_LABELS,
|
||||
WIDGET_SIZES,
|
||||
@@ -185,8 +185,12 @@ export function DashboardEditor({
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const save = useSaveDashboard(dashboard.name)
|
||||
const publish = usePublishDashboard(dashboard.name)
|
||||
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
|
||||
// going out with the version it is about to replace.
|
||||
const inflight = useRef<Promise<void> | null>(null)
|
||||
// The saved version is what the next save is based on; without following it
|
||||
// the second save of a session is always a conflict.
|
||||
const version = useRef(dashboard.version)
|
||||
@@ -195,23 +199,43 @@ export function DashboardEditor({
|
||||
version.current = dashboard.version
|
||||
}, [dashboard.version])
|
||||
|
||||
const store = (next: Dashboard) => {
|
||||
const request = save
|
||||
.mutateAsync({ ...next, version: version.current })
|
||||
.then((saved) => {
|
||||
version.current = saved.version
|
||||
})
|
||||
.catch((error) => handleError.call(showErrorToast, error as ApiError))
|
||||
inflight.current = request
|
||||
return request
|
||||
}
|
||||
|
||||
const commit = (next: Dashboard) => {
|
||||
setDraft(next)
|
||||
if (timer.current) clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => {
|
||||
save.mutate(
|
||||
{ ...next, version: version.current },
|
||||
{
|
||||
onSuccess: (saved) => {
|
||||
version.current = saved.version
|
||||
},
|
||||
onError: (error) =>
|
||||
handleError.call(showErrorToast, error as ApiError),
|
||||
},
|
||||
)
|
||||
timer.current = null
|
||||
void store(next)
|
||||
}, AUTOSAVE_MS)
|
||||
}
|
||||
|
||||
/** Send what is queued and wait for the server to have it. */
|
||||
const flush = async () => {
|
||||
if (timer.current) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
await store(draft)
|
||||
return
|
||||
}
|
||||
await inflight.current
|
||||
}
|
||||
|
||||
/** Put the stored draft live. Publishing what is queued means saving first. */
|
||||
const publishDashboard = async () => {
|
||||
await flush()
|
||||
publish.mutate(version.current ?? 1)
|
||||
}
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: () => DashboardsService.deleteDashboard({ name: draft.name }),
|
||||
onSuccess: () => {
|
||||
@@ -221,6 +245,9 @@ export function DashboardEditor({
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
// What the server says about the stored document, not about the local edit:
|
||||
// a save in flight is still "no unpublished changes" until it lands.
|
||||
const hasDraft = Boolean(dashboard.has_draft)
|
||||
const columns = columnsOf(draft)
|
||||
const pages = pagesOf(draft)
|
||||
const page = pages.find((candidate) => candidate.id === pageId) ?? pages[0]
|
||||
@@ -478,10 +505,27 @@ export function DashboardEditor({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{save.isPending ? "Saving" : "All changes saved"}
|
||||
{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" />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: ["dashboards"] as const,
|
||||
detail: (name: string) => ["dashboards", name] as const,
|
||||
/** The working copy and the published document are two different reads. */
|
||||
detail: (name: string, draft = false) =>
|
||||
["dashboards", name, draft ? "draft" : "published"] as const,
|
||||
messages: ["messages"] as const,
|
||||
history: (message: string) => ["messages", message, "history"] as const,
|
||||
}
|
||||
@@ -18,9 +20,15 @@ export const dashboardsQueryOptions = () => ({
|
||||
queryFn: () => DashboardsService.readDashboards(),
|
||||
})
|
||||
|
||||
export const dashboardQueryOptions = (name: string) => ({
|
||||
queryKey: dashboardKeys.detail(name),
|
||||
queryFn: () => DashboardsService.readDashboard({ name }),
|
||||
/**
|
||||
* A dashboard as a panel shows it, or with `draft` the copy being edited.
|
||||
*
|
||||
* A wall panel asks for the published one, which is the whole point of the
|
||||
* split: nothing half-arranged reaches the wall until someone publishes.
|
||||
*/
|
||||
export const dashboardQueryOptions = (name: string, draft = false) => ({
|
||||
queryKey: dashboardKeys.detail(name, draft),
|
||||
queryFn: () => DashboardsService.readDashboard({ name, draft }),
|
||||
})
|
||||
|
||||
/** Every message any flow declares — what a widget can be pointed at. */
|
||||
@@ -34,14 +42,34 @@ export const messageHistoryQueryOptions = (message: string) => ({
|
||||
queryFn: () => MessagesService.readMessageHistory({ name: message }),
|
||||
})
|
||||
|
||||
/** Saving a dashboard, carrying the version it was based on. */
|
||||
/** Saving a dashboard, carrying the version it was based on.
|
||||
*
|
||||
* This writes the draft; panels keep showing the published document.
|
||||
*/
|
||||
export function useSaveDashboard(name: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: DashboardDef_Input) =>
|
||||
DashboardsService.saveDashboard({ name, requestBody: body }),
|
||||
onSuccess: (saved) => {
|
||||
queryClient.setQueryData(dashboardKeys.detail(name), saved)
|
||||
queryClient.setQueryData(dashboardKeys.detail(name, true), saved)
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Publish the unpublished changes, which is what puts them on the panels. */
|
||||
export function usePublishDashboard(name: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (version: number) =>
|
||||
DashboardsService.publishDashboard({
|
||||
name,
|
||||
requestBody: { version },
|
||||
}),
|
||||
onSuccess: (published) => {
|
||||
queryClient.setQueryData(dashboardKeys.detail(name, true), published)
|
||||
queryClient.setQueryData(dashboardKeys.detail(name), published)
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user