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
This commit is contained in:
@@ -58,6 +58,7 @@ a token. Password recovery uses `POST /password-recovery/{email}` and
|
||||
| `POST` | `/flows/{name}/publish` | publish; send `{"version": <current-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` |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{selectedCount > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-touch"
|
||||
className="text-muted-foreground"
|
||||
disabled={duplicating}
|
||||
aria-label={`Duplicate ${selectedCount} selected`}
|
||||
data-testid="duplicate-selected"
|
||||
onClick={onDuplicateSelected}
|
||||
>
|
||||
{duplicating ? <FluksioLoader /> : <Copy />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Duplicate {selectedCount} selected</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{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<unknown>,
|
||||
function useBatch(
|
||||
act: (name: string) => Promise<unknown>,
|
||||
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<unknown>,
|
||||
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<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"}`,
|
||||
)
|
||||
},
|
||||
})
|
||||
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<unknown>,
|
||||
noun: string,
|
||||
onDone: () => void,
|
||||
) {
|
||||
return useBatch(duplicate, noun, "Duplicated", "Not duplicated", onDone)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1">
|
||||
@@ -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)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -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 (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-1">
|
||||
@@ -122,6 +132,8 @@ function Flows() {
|
||||
setWithRuns(false)
|
||||
setDeleteOpen(true)
|
||||
}}
|
||||
duplicating={duplicate.isPending}
|
||||
onDuplicateSelected={() => duplicate.mutate(selection.selected)}
|
||||
/>
|
||||
<DialogContent>
|
||||
<form
|
||||
|
||||
Reference in New Issue
Block a user