feat(plugins): add duplicate plugin functionality and related API endpoints
- Implemented a new `duplicatePlugin` function to allow copying of installed plugins with a new ID. - Added API endpoint for duplicating plugins, including error handling for various edge cases. - Introduced frontend hooks and UI components for duplicating plugins in the script management interface. - Enhanced tests to validate the duplication process and ensure proper error handling.
This commit is contained in:
@@ -59,6 +59,23 @@ export function useForkScript() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicatePlugin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, id, description }) =>
|
||||
(
|
||||
await api.post(`/scripts/${encodeURIComponent(name)}/duplicate`, {
|
||||
id,
|
||||
description,
|
||||
})
|
||||
).data,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["scripts"] });
|
||||
qc.invalidateQueries({ queryKey: ["ops-status"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useInstallPlugin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useCreatePlugin,
|
||||
useDuplicatePlugin,
|
||||
useForkScript,
|
||||
useSaveScript,
|
||||
useScript,
|
||||
@@ -119,9 +120,11 @@ export function ScriptEditPage() {
|
||||
const existing = useScript(name);
|
||||
const save = useSaveScript();
|
||||
const fork = useForkScript();
|
||||
const duplicate = useDuplicatePlugin();
|
||||
const [content, setContent] = useState("");
|
||||
const [contentReady, setContentReady] = useState(false);
|
||||
const [forkId, setForkId] = useState("");
|
||||
const [duplicateId, setDuplicateId] = useState("");
|
||||
|
||||
const isCore = existing.data?.kind === "core" || existing.data?.editable === false;
|
||||
|
||||
@@ -163,6 +166,21 @@ export function ScriptEditPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function onDuplicate(e) {
|
||||
e.preventDefault();
|
||||
const id = normalizePluginId(duplicateId);
|
||||
if (!id) return;
|
||||
duplicate.mutate(
|
||||
{ name, id },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
notify.success("Duplicated — drain-restart recommended");
|
||||
navigate(`/scripts/${encodeURIComponent(data.scriptRef)}/edit`);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (existing.isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[12rem] items-center justify-center">
|
||||
@@ -238,7 +256,27 @@ export function ScriptEditPage() {
|
||||
<span className="text-error text-sm">{errorMessage(fork.error)}</span>
|
||||
) : null}
|
||||
</form>
|
||||
) : null}
|
||||
) : (
|
||||
<form onSubmit={onDuplicate} className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input input-sm font-mono w-56"
|
||||
placeholder="duplicate id (e.g. my-plugin-copy)"
|
||||
value={duplicateId}
|
||||
onChange={(e) => setDuplicateId(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-sm"
|
||||
disabled={duplicate.isPending || !normalizePluginId(duplicateId)}
|
||||
>
|
||||
<LuCopy className="size-4" />
|
||||
Duplicate plugin
|
||||
</button>
|
||||
{duplicate.isError ? (
|
||||
<span className="text-error text-sm">{errorMessage(duplicate.error)}</span>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
|
||||
<CodeEditor
|
||||
|
||||
@@ -4,6 +4,7 @@ import { LuCopy, LuPencil, LuPlay, LuPlus, LuSearch, LuTrash2 } from "react-icon
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDeleteScript,
|
||||
useDuplicatePlugin,
|
||||
useForkScript,
|
||||
useInstallPlugin,
|
||||
useScripts,
|
||||
@@ -23,8 +24,11 @@ export function ScriptsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [forkFor, setForkFor] = useState(null);
|
||||
const [forkId, setForkId] = useState("");
|
||||
const [duplicateFor, setDuplicateFor] = useState(null);
|
||||
const [duplicateId, setDuplicateId] = useState("");
|
||||
const del = useDeleteScript();
|
||||
const fork = useForkScript();
|
||||
const duplicate = useDuplicatePlugin();
|
||||
const install = useInstallPlugin();
|
||||
const { notify } = useNotifications();
|
||||
|
||||
@@ -182,14 +186,33 @@ export function ScriptsPage() {
|
||||
<LuCopy className="size-4" />
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</Link>
|
||||
<>
|
||||
<Link
|
||||
to={`/scripts/${encodeURIComponent(name)}/edit`}
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Edit"
|
||||
aria-label="Edit"
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Duplicate"
|
||||
aria-label="Duplicate"
|
||||
onClick={() => {
|
||||
setDuplicateFor(name);
|
||||
setDuplicateId(
|
||||
String(name)
|
||||
.replace(/^plugin\//i, "")
|
||||
.replace(/\.js$/i, "")
|
||||
.toLowerCase() + "-copy",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<LuCopy className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isCore ? (
|
||||
<button
|
||||
@@ -264,6 +287,60 @@ export function ScriptsPage() {
|
||||
</dialog>
|
||||
) : null}
|
||||
|
||||
{duplicateFor ? (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-semibold">Duplicate {duplicateFor}</h3>
|
||||
<p className="text-sm opacity-70 py-2">
|
||||
Creates <code>plugin/<id></code> from this plugin.
|
||||
</p>
|
||||
<input
|
||||
className="input input-bordered input-sm w-full font-mono"
|
||||
value={duplicateId}
|
||||
onChange={(e) => setDuplicateId(e.target.value)}
|
||||
/>
|
||||
{duplicate.isError ? (
|
||||
<p className="text-error text-sm mt-2">{errorMessage(duplicate.error)}</p>
|
||||
) : null}
|
||||
<div className="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setDuplicateFor(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={duplicate.isPending || !duplicateId.trim()}
|
||||
onClick={() =>
|
||||
duplicate.mutate(
|
||||
{ name: duplicateFor, id: duplicateId.trim() },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setDuplicateFor(null);
|
||||
notify.success("Duplicated — drain-restart recommended");
|
||||
navigate(
|
||||
`/scripts/${encodeURIComponent(data.scriptRef)}/edit`,
|
||||
);
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={() => setDuplicateFor(null)}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDelete)}
|
||||
title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
|
||||
|
||||
Reference in New Issue
Block a user