feat: add workflow duplication functionality with API endpoint and UI button
This commit is contained in:
@@ -149,6 +149,26 @@ export class Workflows {
|
|||||||
return res.status(204).send();
|
return res.status(204).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /workflows/:id/duplicate
|
||||||
|
* Duplicate a workflow (always disabled, no execution state)
|
||||||
|
*/
|
||||||
|
@Post(':id/duplicate')
|
||||||
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
|
@CatchAsync
|
||||||
|
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
|
const workflowId = req.params.id;
|
||||||
|
|
||||||
|
if (!workflowId) {
|
||||||
|
return res.status(400).json({error: 'Workflow ID is required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await WorkflowService.duplicate(auth.projectId!, workflowId);
|
||||||
|
|
||||||
|
return res.status(201).json(workflow);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /workflows/:id/steps
|
* POST /workflows/:id/steps
|
||||||
* Add a step to a workflow
|
* Add a step to a workflow
|
||||||
|
|||||||
@@ -310,6 +310,72 @@ export class WorkflowService {
|
|||||||
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
|
await NtfyService.notifyWorkflowDeleted(workflow.name, workflow.project.name, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate a workflow including all steps and transitions.
|
||||||
|
* The duplicate always starts disabled to prevent accidental triggering.
|
||||||
|
* Runtime execution state is intentionally not copied.
|
||||||
|
*/
|
||||||
|
public static async duplicate(projectId: string, workflowId: string): Promise<Workflow> {
|
||||||
|
const source = await this.get(projectId, workflowId);
|
||||||
|
|
||||||
|
const transitions = await prisma.workflowTransition.findMany({
|
||||||
|
where: {fromStep: {workflowId}},
|
||||||
|
});
|
||||||
|
|
||||||
|
return prisma.$transaction(async tx => {
|
||||||
|
const newWorkflow = await tx.workflow.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
name: `${source.name} (Copy)`,
|
||||||
|
description: source.description,
|
||||||
|
triggerType: source.triggerType,
|
||||||
|
triggerConfig:
|
||||||
|
source.triggerConfig === null
|
||||||
|
? Prisma.JsonNull
|
||||||
|
: (source.triggerConfig as Prisma.InputJsonValue),
|
||||||
|
enabled: false,
|
||||||
|
allowReentry: source.allowReentry,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stepIdMap = new Map<string, string>();
|
||||||
|
|
||||||
|
for (const step of source.steps) {
|
||||||
|
const created = await tx.workflowStep.create({
|
||||||
|
data: {
|
||||||
|
workflowId: newWorkflow.id,
|
||||||
|
type: step.type,
|
||||||
|
name: step.name,
|
||||||
|
position: step.position as Prisma.InputJsonValue,
|
||||||
|
config: step.config as Prisma.InputJsonValue,
|
||||||
|
templateId: step.templateId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
stepIdMap.set(step.id, created.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const transition of transitions) {
|
||||||
|
const fromStepId = stepIdMap.get(transition.fromStepId);
|
||||||
|
const toStepId = stepIdMap.get(transition.toStepId);
|
||||||
|
if (!fromStepId || !toStepId) continue;
|
||||||
|
|
||||||
|
await tx.workflowTransition.create({
|
||||||
|
data: {
|
||||||
|
fromStepId,
|
||||||
|
toStepId,
|
||||||
|
condition:
|
||||||
|
transition.condition === null
|
||||||
|
? Prisma.JsonNull
|
||||||
|
: (transition.condition as Prisma.InputJsonValue),
|
||||||
|
priority: transition.priority,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return newWorkflow;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a step to a workflow
|
* Add a step to a workflow
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {EmptyState} from '@plunk/ui';
|
|||||||
import {DashboardLayout} from '../../components/DashboardLayout';
|
import {DashboardLayout} from '../../components/DashboardLayout';
|
||||||
import {network} from '../../lib/network';
|
import {network} from '../../lib/network';
|
||||||
import {formatRelativeTime} from '../../lib/dateUtils';
|
import {formatRelativeTime} from '../../lib/dateUtils';
|
||||||
import {Calendar, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
|
import {Calendar, Copy, Edit, Plus, Power, PowerOff, Search, Trash2, Workflow as WorkflowIcon, X, Zap} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
@@ -66,6 +66,16 @@ export default function WorkflowsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = async (workflowId: string) => {
|
||||||
|
try {
|
||||||
|
await network.fetch('POST', `/workflows/${workflowId}/duplicate`);
|
||||||
|
toast.success('Workflow duplicated successfully');
|
||||||
|
void mutate();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Failed to duplicate workflow');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
|
const handleToggleEnabled = async (workflowId: string, currentlyEnabled: boolean) => {
|
||||||
try {
|
try {
|
||||||
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
|
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${workflowId}`, {
|
||||||
@@ -219,6 +229,14 @@ export default function WorkflowsPage() {
|
|||||||
<Button asChild variant="ghost" size="sm" title="Edit workflow">
|
<Button asChild variant="ghost" size="sm" title="Edit workflow">
|
||||||
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link>
|
<Link href={`/workflows/${workflow.id}`} aria-label="Edit workflow"><Edit className="h-4 w-4" /></Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
title="Duplicate workflow"
|
||||||
|
onClick={() => handleDuplicate(workflow.id)}
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user