From 0319842ab40b4b4e5864f916aacbad95c8041063 Mon Sep 17 00:00:00 2001 From: stroblme Date: Fri, 16 Oct 2026 09:21:42 +0200 Subject: [PATCH] Overview lists: a duplicate button beside the trash while selecting copies the picked flows or dashboards as drafts, one request each, then clears the selection. Publish, delete and duplicate share one batch helper with their own toasts. Docs Co-Authored-By: Claude Opus 5.5 --- docs/code/api.md | 2 + docs/interface/index.md | 9 +- .../src/components/Common/OverviewToolbar.tsx | 111 +++++++++++------- .../src/routes/_layout/dashboards/index.tsx | 12 ++ frontend/src/routes/_layout/flows/index.tsx | 12 ++ 5 files changed, 98 insertions(+), 48 deletions(-) diff --git a/docs/code/api.md b/docs/code/api.md index 2e002ad..face50b 100644 --- a/docs/code/api.md +++ b/docs/code/api.md @@ -58,6 +58,7 @@ a token. Password recovery uses `POST /password-recovery/{email}` and | `POST` | `/flows/{name}/publish` | publish; send `{"version": }` | | `POST` | `/flows/{name}/discard-draft` | discard unpublished edits | | `POST` | `/flows/{name}/rename` | rename | +| `POST` | `/flows/{name}/duplicate` | copy into an unpublished `{name}_copy`; code nodes need a superuser | | `DELETE` | `/flows/{name}` | delete; refused while a run is queued or running | | `GET` | `/flows/node-types` | list types and parameter schemas | | `GET` | `/flows/graph` | read the graph shown on Home | @@ -188,6 +189,7 @@ on its own, so this is for reclaiming space sooner. | `POST` | `/dashboards/from-flow/{flow}` | create a batch flow's results dashboard | | `PUT` | `/dashboards/{name}` | save a draft | | `POST` | `/dashboards/{name}/publish`, `/dashboards/{name}/discard`, `/dashboards/{name}/rename` | publish, discard, or rename | +| `POST` | `/dashboards/{name}/duplicate` | copy into an unpublished `{name}_copy` | | `GET`, `PUT` | `/panels/` | read or replace panel assignments; send the current `version` | | `POST` | `/panels/pair` | start device pairing | | `GET` | `/panels/pair/{code}` | poll pairing with its `secret` | diff --git a/docs/interface/index.md b/docs/interface/index.md index ba86273..ffa5389 100644 --- a/docs/interface/index.md +++ b/docs/interface/index.md @@ -76,9 +76,12 @@ The lists show titles, unpublished changes and status. Use **New flow** or **New dashboard** to create one. The Flows list offers **Publish all changes** when drafts are waiting. -To delete several items, hold a card or Ctrl-click it, then select the rest. -The create button becomes a trash button. Deletion asks for confirmation; -Escape clears the selection. +To delete or duplicate several items, hold a card or Ctrl-click it, then +select the rest. The create button becomes a trash button with a duplicate +button beside it. Deletion asks for confirmation; Escape clears the selection. +A duplicate is named after the original with `_copy` and starts as an +unpublished draft, so it runs nothing and no panel shows it until you publish +it. ## Appearance diff --git a/frontend/src/components/Common/OverviewToolbar.tsx b/frontend/src/components/Common/OverviewToolbar.tsx index 2512290..a5c64b1 100644 --- a/frontend/src/components/Common/OverviewToolbar.tsx +++ b/frontend/src/components/Common/OverviewToolbar.tsx @@ -1,5 +1,5 @@ import { useMutation } from "@tanstack/react-query" -import { Check, Plus, Search, Trash2 } from "lucide-react" +import { Check, Copy, Plus, Search, Trash2 } from "lucide-react" import { motion } from "motion/react" import { type ReactNode, useRef, useState } from "react" @@ -33,8 +33,8 @@ import { transitions } from "@/lib/motion" * * 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. + * is selected it stands down for the trash instead, with duplicate beside it: + * the actions on the row are whichever the list is currently for. */ export function OverviewToolbar({ search, @@ -48,6 +48,8 @@ export function OverviewToolbar({ onPublishAll, selectedCount = 0, onDeleteSelected, + duplicating = false, + onDuplicateSelected, children, }: { search: string @@ -63,6 +65,8 @@ export function OverviewToolbar({ /** How many entries are picked; above zero the list is in selection mode. */ selectedCount?: number onDeleteSelected?: () => void + duplicating?: boolean + onDuplicateSelected?: () => void /** Anything this particular overview adds, drawn ahead of the shared icons. */ children?: ReactNode }) { @@ -121,6 +125,25 @@ export function OverviewToolbar({ )} + {selectedCount > 0 ? ( + + + + + Duplicate {selectedCount} selected + + ) : null} + {selectedCount > 0 ? ( @@ -182,16 +205,22 @@ export function OverviewToolbar({ } /** - * Publish several documents in turn, and say how many actually made it. + * Do one thing to several documents in turn, and say how many it reached. * - * 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. + * Each one is its own request, with its own version precondition where there + * is one, so a document someone else has moved past or already removed, or + * that the server refuses, fails on its own rather than taking the batch with + * it. The toast names the ones missed instead of reporting a success that did + * not happen. + * + * @param done what the toast says happened to them, "Published" + * @param missed what the ones that failed still are, "Still unpublished" */ -export function usePublishAll( - publish: (name: string) => Promise, +function useBatch( + act: (name: string) => Promise, noun: string, + done: string, + missed: string, onDone: () => void, ) { const { showSuccessToast, showErrorToast } = useCustomToast() @@ -200,7 +229,7 @@ export function usePublishAll( const failed: string[] = [] for (const name of names) { try { - await publish(name) + await act(name) } catch { failed.push(name) } @@ -212,55 +241,47 @@ export function usePublishAll( onSuccess: (failed, names) => { if (failed.length) showErrorToast( - `Published ${names.length - failed.length} of ${names.length}. Still unpublished: ${failed.join(", ")}`, + `${done} ${names.length - failed.length} of ${names.length}. ${missed}: ${failed.join(", ")}`, ) else showSuccessToast( - `Published ${names.length} ${noun}${names.length === 1 ? "" : "s"}`, + `${done} ${names.length} ${noun}${names.length === 1 ? "" : "s"}`, ) }, }) } +/** Publish several documents, each against the version the list holds. */ +export function usePublishAll( + publish: (name: string) => Promise, + noun: string, + onDone: () => void, +) { + return useBatch(publish, noun, "Published", "Still unpublished", onDone) +} + /** - * 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. + * Delete the selected documents. 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"}`, - ) - }, - }) + return useBatch(remove, noun, "Deleted", "Still there", onDone) +} + +/** + * Copy the selected documents, each into a new one the server names. A copy + * is an unpublished draft, like a new document, so nothing is asked first. + */ +export function useDuplicateSelected( + duplicate: (name: string) => Promise, + noun: string, + onDone: () => void, +) { + return useBatch(duplicate, noun, "Duplicated", "Not duplicated", onDone) } /** diff --git a/frontend/src/routes/_layout/dashboards/index.tsx b/frontend/src/routes/_layout/dashboards/index.tsx index ec82dbd..846068a 100644 --- a/frontend/src/routes/_layout/dashboards/index.tsx +++ b/frontend/src/routes/_layout/dashboards/index.tsx @@ -9,6 +9,7 @@ import { ConfirmDelete, OverviewToolbar, useDeleteSelected, + useDuplicateSelected, usePublishAll, } from "@/components/Common/OverviewToolbar" import { PanelsDialog } from "@/components/Dashboard/PanelsDialog" @@ -112,6 +113,15 @@ function Dashboards() { () => queryClient.invalidateQueries({ queryKey: dashboardKeys.all }), ) + const duplicate = useDuplicateSelected( + (dashboard) => DashboardsService.duplicateDashboard({ name: dashboard }), + "dashboard", + () => { + selection.clear() + queryClient.invalidateQueries({ queryKey: dashboardKeys.all }) + }, + ) + return (
@@ -140,6 +150,8 @@ function Dashboards() { onPublishAll={() => publishAll.mutate(drafts)} selectedCount={selection.selected.length} onDeleteSelected={() => setDeleteOpen(true)} + duplicating={duplicate.isPending} + onDuplicateSelected={() => duplicate.mutate(selection.selected)} > diff --git a/frontend/src/routes/_layout/flows/index.tsx b/frontend/src/routes/_layout/flows/index.tsx index 4204343..306146b 100644 --- a/frontend/src/routes/_layout/flows/index.tsx +++ b/frontend/src/routes/_layout/flows/index.tsx @@ -9,6 +9,7 @@ import { ConfirmDelete, OverviewToolbar, useDeleteSelected, + useDuplicateSelected, usePublishAll, } from "@/components/Common/OverviewToolbar" import { AlsoDeleteRuns } from "@/components/Flow/FlowPanel" @@ -96,6 +97,15 @@ function Flows() { () => queryClient.invalidateQueries({ queryKey: flowKeys.all }), ) + const duplicate = useDuplicateSelected( + (flow) => FlowsService.duplicateFlow({ name: flow }), + "flow", + () => { + selection.clear() + queryClient.invalidateQueries({ queryKey: flowKeys.all }) + }, + ) + return (
@@ -122,6 +132,8 @@ function Flows() { setWithRuns(false) setDeleteOpen(true) }} + duplicating={duplicate.isPending} + onDuplicateSelected={() => duplicate.mutate(selection.selected)} />