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)} />