Follows the portal: the noun is "instance" everywhere the app says it — UI strings, CLI output, error details, docs and comments. The wire keys (`instance_id`, `instance_token`) and the hub route this calls move with it. An existing cloud.json is adopted rather than refused: without the key alias the dataclass fails to parse, which the caller swallows and reads as "never enrolled" instead of "reconnect". `instance_key` on a node type becomes `target_key`. It means the outside thing a node points at, which is a different sense of the word, and keeping both would put two meanings of "instance" in one codebase. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
331 lines
9.8 KiB
TypeScript
331 lines
9.8 KiB
TypeScript
import { useMutation } from "@tanstack/react-query"
|
|
import { Check, Plus, Search, Trash2 } from "lucide-react"
|
|
import { motion } from "motion/react"
|
|
import { type ReactNode, useRef, useState } from "react"
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog"
|
|
import { FluksioLoader } from "@/components/ui/fluksio-loader"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip"
|
|
import useCustomToast from "@/hooks/useCustomToast"
|
|
import { transitions } from "@/lib/motion"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
/** 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. While anything in the list
|
|
* is selected it stands down for the trash instead: the row keeps its three
|
|
* targets, and the one primary action is whichever the list is currently for.
|
|
*/
|
|
export function OverviewToolbar({
|
|
search,
|
|
onSearch,
|
|
searchLabel,
|
|
searchTestId,
|
|
createLabel,
|
|
createTestId,
|
|
draftCount,
|
|
publishing,
|
|
onPublishAll,
|
|
selectedCount = 0,
|
|
onDeleteSelected,
|
|
children,
|
|
}: {
|
|
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
|
|
/** How many entries are picked; above zero the list is in selection mode. */
|
|
selectedCount?: number
|
|
onDeleteSelected?: () => void
|
|
/** Anything this particular overview adds, drawn ahead of the shared icons. */
|
|
children?: ReactNode
|
|
}) {
|
|
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">
|
|
{children}
|
|
{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>
|
|
{selectedCount > 0 ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn(ICON, "text-destructive")}
|
|
aria-label={`Delete ${selectedCount} selected`}
|
|
data-testid="delete-selected"
|
|
onClick={onDeleteSelected}
|
|
>
|
|
<Trash2 />
|
|
</Button>
|
|
) : (
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={ICON}
|
|
aria-label={createLabel}
|
|
data-testid={createTestId}
|
|
>
|
|
<Plus />
|
|
</Button>
|
|
</DialogTrigger>
|
|
)}
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{selectedCount > 0 ? `Delete ${selectedCount} selected` : createLabel}
|
|
</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 ? <FluksioLoader /> : <Check />}
|
|
</Button>
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
{draftCount === 0
|
|
? "Nothing unpublished"
|
|
: `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`}
|
|
</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"}`,
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Delete the selected documents, and say how many actually went.
|
|
*
|
|
* Same one-request-each shape as `usePublishAll`: one that someone else has
|
|
* already removed, or that the server refuses, fails on its own rather than
|
|
* taking the rest of the batch with it. Nothing here clears the selection —
|
|
* the list coming back without those names is what does that.
|
|
*/
|
|
export function useDeleteSelected(
|
|
remove: (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 remove(name)
|
|
} catch {
|
|
failed.push(name)
|
|
}
|
|
}
|
|
return failed
|
|
},
|
|
// Some of them are gone even when others are not.
|
|
onSettled: onDone,
|
|
onSuccess: (failed, names) => {
|
|
if (failed.length)
|
|
showErrorToast(
|
|
`Deleted ${names.length - failed.length} of ${names.length}. Still there: ${failed.join(", ")}`,
|
|
)
|
|
else
|
|
showSuccessToast(
|
|
`Deleted ${names.length} ${noun}${names.length === 1 ? "" : "s"}`,
|
|
)
|
|
},
|
|
})
|
|
}
|
|
|
|
/**
|
|
* The one question a batch delete asks, however many were picked.
|
|
*
|
|
* Names the single one it is about, counts the rest: a list of twelve names in
|
|
* a dialog is read as decoration rather than as a check.
|
|
*/
|
|
export function ConfirmDelete({
|
|
open,
|
|
onOpenChange,
|
|
names,
|
|
noun,
|
|
pending,
|
|
description,
|
|
onConfirm,
|
|
}: {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
names: string[]
|
|
noun: string
|
|
pending: boolean
|
|
/** What is actually lost, when the git-backed answer below is not it. */
|
|
description?: string
|
|
onConfirm: () => void
|
|
}) {
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{names.length === 1
|
|
? `Delete "${names[0]}"?`
|
|
: `Delete these ${names.length} ${noun}s?`}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{description ?? (
|
|
<>
|
|
{names.length === 1 ? "It goes" : "They go"} from the instance
|
|
at once. The store's git history keeps what was there, but
|
|
nothing in the app brings {names.length === 1 ? "it" : "them"}{" "}
|
|
back.
|
|
</>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
Keep {names.length === 1 ? "it" : "them"}
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
disabled={pending}
|
|
onClick={onConfirm}
|
|
data-testid="confirm-delete"
|
|
>
|
|
Delete {names.length === 1 ? noun : `${names.length} ${noun}s`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|