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:
2026-08-17 11:57:11 +02:00
co-authored by Claude Opus 5
parent 683c25b26d
commit 9351a86eec
14 changed files with 713 additions and 144 deletions
+27
View File
@@ -359,6 +359,11 @@ export const DashboardDef_InputSchema = {
type: 'integer',
title: 'Version',
default: 1
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
}
},
type: 'object',
@@ -410,6 +415,11 @@ export const DashboardDef_OutputSchema = {
type: 'integer',
title: 'Version',
default: 1
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
}
},
type: 'object',
@@ -438,6 +448,11 @@ export const DashboardSummarySchema = {
type: 'integer',
title: 'Widget Count',
default: 0
},
has_draft: {
type: 'boolean',
title: 'Has Draft',
default: false
}
},
type: 'object',
@@ -2501,6 +2516,18 @@ message it publishes — and is validated against the type below rather than
by a schema per class, because the whole set is small and closed.`
} as const;
export const app__api__routes__dashboards__PublishRequestSchema = {
properties: {
version: {
type: 'integer',
title: 'Version'
}
},
type: 'object',
required: ['version'],
title: 'PublishRequest'
} as const;
export const app__api__routes__dashboards__RenameRequestSchema = {
properties: {
name: {
+56 -2
View File
@@ -3,7 +3,7 @@
import type { CancelablePromise } from './core/CancelablePromise';
import { OpenAPI } from './core/OpenAPI';
import { request as __request } from './core/request';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
import type { AlertsReadAlertsConfigResponse, AlertsSaveAlertsConfigData, AlertsSaveAlertsConfigResponse, AlertsTestChannelData, AlertsTestChannelResponse, DashboardsReadDashboardsResponse, DashboardsReadDashboardData, DashboardsReadDashboardResponse, DashboardsCreateDashboardData, DashboardsCreateDashboardResponse, DashboardsSaveDashboardData, DashboardsSaveDashboardResponse, DashboardsDeleteDashboardData, DashboardsDeleteDashboardResponse, DashboardsPublishDashboardData, DashboardsPublishDashboardResponse, DashboardsDiscardDashboardDraftData, DashboardsDiscardDashboardDraftResponse, DashboardsRenameDashboardData, DashboardsRenameDashboardResponse, FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadGraphResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsStepFlowData, FlowsStepFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsCancelNodeData, FlowsCancelNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, MessagesReadMessagesResponse, MessagesPublishMessageData, MessagesPublishMessageResponse, MessagesReadMessageHistoryData, MessagesReadMessageHistoryResponse, ModulesReadModulesResponse, ModulesApplyModulesData, ModulesApplyModulesResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, OauthReadClientsResponse, OauthRevokeClientData, OauthRevokeClientResponse, ObservabilityReadSummaryResponse, ObservabilityReadTimeseriesData, ObservabilityReadTimeseriesResponse, ObservabilityReadFlowRollupsData, ObservabilityReadFlowRollupsResponse, ObservabilityReadRunsData, ObservabilityReadRunsResponse, ObservabilityReadEventsData, ObservabilityReadEventsResponse, ObservabilityReadDeadLettersData, ObservabilityReadDeadLettersResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse, UtilsHealthResponse } from './types.gen';
export class AlertsService {
/**
@@ -77,8 +77,10 @@ export class DashboardsService {
/**
* Read Dashboard
* What a panel shows, or with ``draft`` the copy the editor is on.
* @param data The data for the request.
* @param data.name
* @param data.draft
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
@@ -89,6 +91,9 @@ export class DashboardsService {
path: {
name: data.name
},
query: {
draft: data.draft
},
errors: {
422: 'Validation Error'
}
@@ -118,7 +123,11 @@ export class DashboardsService {
/**
* Save Dashboard
* Replace a dashboard, refusing a save someone else has moved past.
* Save unpublished changes, refusing a save someone else has moved past.
*
* This writes a draft: panels keep showing the published document until
* someone publishes, so nothing here can change what a wall is displaying —
* which is also why the engine's history limits are left alone.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
@@ -160,6 +169,51 @@ export class DashboardsService {
});
}
/**
* Publish Dashboard
* Put the unpublished changes on the panels.
* @param data The data for the request.
* @param data.name
* @param data.requestBody
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static publishDashboard(data: DashboardsPublishDashboardData): CancelablePromise<DashboardsPublishDashboardResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/dashboards/{name}/publish',
path: {
name: data.name
},
body: data.requestBody,
mediaType: 'application/json',
errors: {
422: 'Validation Error'
}
});
}
/**
* Discard Dashboard Draft
* Throw the unpublished changes away and go back to what is shown.
* @param data The data for the request.
* @param data.name
* @returns DashboardDef_Output Successful Response
* @throws ApiError
*/
public static discardDashboardDraft(data: DashboardsDiscardDashboardDraftData): CancelablePromise<DashboardsDiscardDashboardDraftResponse> {
return __request(OpenAPI, {
method: 'POST',
url: '/api/v1/dashboards/{name}/discard',
path: {
name: data.name
},
errors: {
422: 'Validation Error'
}
});
}
/**
* Rename Dashboard
* @param data The data for the request.
+21
View File
@@ -9,6 +9,10 @@ export type AlertsConfig = {
rules?: Array<Rule>;
};
export type app__api__routes__dashboards__PublishRequest = {
version: number;
};
export type app__api__routes__dashboards__RenameRequest = {
name: string;
};
@@ -132,6 +136,7 @@ export type DashboardDef_Input = {
canvas_height?: number;
pages?: Array<PageDef_Input>;
version?: number;
has_draft?: boolean;
};
/**
@@ -145,6 +150,7 @@ export type DashboardDef_Output = {
canvas_height?: number;
pages?: Array<PageDef_Output>;
version?: number;
has_draft?: boolean;
};
export type DashboardsPublic = {
@@ -160,6 +166,7 @@ export type DashboardSummary = {
title?: string;
page_count?: number;
widget_count?: number;
has_draft?: boolean;
};
export type DeadLetter = {
@@ -785,6 +792,7 @@ export type AlertsTestChannelResponse = (Message);
export type DashboardsReadDashboardsResponse = (DashboardsPublic);
export type DashboardsReadDashboardData = {
draft?: boolean;
name: string;
};
@@ -809,6 +817,19 @@ export type DashboardsDeleteDashboardData = {
export type DashboardsDeleteDashboardResponse = (Message);
export type DashboardsPublishDashboardData = {
name: string;
requestBody: app__api__routes__dashboards__PublishRequest;
};
export type DashboardsPublishDashboardResponse = (DashboardDef_Output);
export type DashboardsDiscardDashboardDraftData = {
name: string;
};
export type DashboardsDiscardDashboardDraftResponse = (DashboardDef_Output);
export type DashboardsRenameDashboardData = {
name: string;
requestBody: app__api__routes__dashboards__RenameRequest;
@@ -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"}`,
)
},
})
}
@@ -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}
+34 -6
View File
@@ -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 })
},
})
@@ -23,7 +23,9 @@ function DashboardRoute() {
const { edit } = Route.useSearch()
// Widgets read live values; the canvas shell has no socket of its own.
useFlowSocket()
const { data: dashboard } = useQuery(dashboardQueryOptions(name))
// The working copy: an edit is the editor's until it is published, so what
// a wall panel is showing right now stays untouched by this session.
const { data: dashboard } = useQuery(dashboardQueryOptions(name, true))
if (!dashboard) return null
@@ -1,9 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { LayoutDashboard, Plus } from "lucide-react"
import { LayoutDashboard } from "lucide-react"
import { useState } from "react"
import { DashboardsService } from "@/client"
import {
OverviewToolbar,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import {
dashboardKeys,
dashboardsQueryOptions,
@@ -16,7 +20,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
@@ -50,6 +53,23 @@ function Dashboards() {
onError: handleError.bind(showErrorToast),
})
// A summary carries no version, and publishing needs the one it is based on —
// so each dashboard's working copy is read right before it is published.
const publishAll = usePublishAll(
async (dashboard) => {
const current = await DashboardsService.readDashboard({
name: dashboard,
draft: true,
})
return DashboardsService.publishDashboard({
name: dashboard,
requestBody: { version: current.version ?? 1 },
})
},
"dashboard",
() => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }),
)
// The store only accepts this shape, so say so before the request does.
const slug = name
.trim()
@@ -59,6 +79,9 @@ function Dashboards() {
const dashboards = (data?.data ?? []).filter((dashboard) =>
`${dashboard.name} ${dashboard.title ?? ""}`.toLowerCase().includes(needle),
)
const drafts = (data?.data ?? [])
.filter((dashboard) => dashboard.has_draft)
.map((dashboard) => dashboard.name)
return (
<div className="grid gap-6">
@@ -69,57 +92,52 @@ function Dashboards() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search dashboards"
aria-label="Search dashboards"
className="max-w-xs"
data-testid="search-dashboards"
onChange={(event) => setSearch(event.target.value)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<OverviewToolbar
search={search}
onSearch={setSearch}
searchLabel="Search dashboards"
searchTestId="search-dashboards"
createLabel="New dashboard"
createTestId="new-dashboard"
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" data-testid="new-dashboard">
<Plus />
Create
</Button>
</DialogTrigger>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New dashboard</DialogTitle>
<DialogDescription>
Name it after the panel it will hang on, or what it shows.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="kitchen"
aria-label="New dashboard name"
autoComplete="off"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
>
Create dashboard
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (slug) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New dashboard</DialogTitle>
<DialogDescription>
Name it after the panel it will hang on, or what it shows.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="kitchen"
aria-label="New dashboard name"
autoComplete="off"
data-testid="new-dashboard-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!slug || create.isPending}
data-testid="create-dashboard"
>
Create dashboard
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
@@ -140,6 +158,11 @@ function Dashboards() {
<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
+67 -52
View File
@@ -1,9 +1,13 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"
import { Plus, Workflow } from "lucide-react"
import { Workflow } from "lucide-react"
import { useState } from "react"
import { FlowsService } from "@/client"
import {
OverviewToolbar,
usePublishAll,
} from "@/components/Common/OverviewToolbar"
import { flowKeys, flowsQueryOptions } from "@/components/Flow/queries"
import { Button } from "@/components/ui/button"
import {
@@ -13,7 +17,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import useCustomToast from "@/hooks/useCustomToast"
@@ -50,6 +53,20 @@ function Flows() {
onError: handleError.bind(showErrorToast),
})
// A summary carries no version, and publishing needs the one it is based
// on — so each flow's current version is read right before it is published.
const publishAll = usePublishAll(
async (flow) => {
const detail = await FlowsService.readFlow({ name: flow })
return FlowsService.publishFlow({
name: flow,
requestBody: { version: detail.definition.version ?? 1 },
})
},
"flow",
() => queryClient.invalidateQueries({ queryKey: flowKeys.all }),
)
const slug = name
.trim()
.toLowerCase()
@@ -58,6 +75,9 @@ function Flows() {
const flows = (data?.data ?? []).filter((flow) =>
`${flow.name} ${flow.title ?? ""}`.toLowerCase().includes(needle),
)
const drafts = (data?.data ?? [])
.filter((flow) => flow.has_draft)
.map((flow) => flow.name)
return (
<div className="grid gap-6">
@@ -69,57 +89,52 @@ function Flows() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Input
value={search}
placeholder="Search flows"
aria-label="Search flows"
className="max-w-xs"
data-testid="search-flows"
onChange={(event) => setSearch(event.target.value)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<OverviewToolbar
search={search}
onSearch={setSearch}
searchLabel="Search flows"
searchTestId="search-flows"
createLabel="New flow"
createTestId="new-flow"
draftCount={drafts.length}
publishing={publishAll.isPending}
onPublishAll={() => publishAll.mutate(drafts)}
/>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary" data-testid="new-flow">
<Plus />
Create
</Button>
</DialogTrigger>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (NAME.test(slug)) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New flow</DialogTitle>
<DialogDescription>
Flows are small on purpose. Name this one after what it does.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="heating"
aria-label="New flow name"
autoComplete="off"
data-testid="new-flow-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!NAME.test(slug) || create.isPending}
data-testid="create-flow"
>
Create flow
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
<DialogContent>
<form
className="grid gap-4"
onSubmit={(event) => {
event.preventDefault()
if (NAME.test(slug)) create.mutate(slug)
}}
>
<DialogHeader>
<DialogTitle>New flow</DialogTitle>
<DialogDescription>
Flows are small on purpose. Name this one after what it does.
</DialogDescription>
</DialogHeader>
<Input
value={name}
placeholder="heating"
aria-label="New flow name"
autoComplete="off"
data-testid="new-flow-name"
onChange={(event) => setName(event.target.value)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!NAME.test(slug) || create.isPending}
data-testid="create-flow"
>
Create flow
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{flows.length === 0 ? (
<p className="text-sm text-muted-foreground">