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(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 (
{children} {open ? ( onSearch(event.target.value)} onBlur={() => { if (!search.trim()) setOpen(false) }} onKeyDown={(event) => { if (event.key === "Escape") collapse() }} /> ) : ( {searchLabel} )} {selectedCount > 0 ? ( ) : ( )} {selectedCount > 0 ? `Delete ${selectedCount} selected` : createLabel} {/* A disabled button gets no pointer events, so the tooltip that explains why it is disabled needs a wrapper to hang on. */} {draftCount === 0 ? "Nothing unpublished" : `Publish ${draftCount} unpublished change${draftCount === 1 ? "" : "s"}`}
) } /** * 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, 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, 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 ( {names.length === 1 ? `Delete "${names[0]}"?` : `Delete these ${names.length} ${noun}s?`} {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. )} ) }