feat: Ability to change workflow trigger
This commit is contained in:
@@ -194,6 +194,8 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use transaction to update workflow and TRIGGER step atomically
|
||||||
|
const updated = await prisma.$transaction(async tx => {
|
||||||
const updateData: Prisma.WorkflowUpdateInput = {};
|
const updateData: Prisma.WorkflowUpdateInput = {};
|
||||||
|
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
@@ -205,7 +207,7 @@ export class WorkflowService {
|
|||||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||||
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
||||||
|
|
||||||
const updated = await prisma.workflow.update({
|
const updatedWorkflow = await tx.workflow.update({
|
||||||
where: {id: workflowId},
|
where: {id: workflowId},
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
@@ -215,11 +217,46 @@ export class WorkflowService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step
|
||||||
|
if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') {
|
||||||
|
const newTriggerConfig = data.triggerConfig as {eventName?: string} | null;
|
||||||
|
const eventName = newTriggerConfig?.eventName;
|
||||||
|
|
||||||
|
if (eventName) {
|
||||||
|
// Find the TRIGGER step
|
||||||
|
const triggerStep = await tx.workflowStep.findFirst({
|
||||||
|
where: {
|
||||||
|
workflowId: workflowId,
|
||||||
|
type: 'TRIGGER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (triggerStep) {
|
||||||
|
// Update TRIGGER step config and name to match
|
||||||
|
await tx.workflowStep.update({
|
||||||
|
where: {id: triggerStep.id},
|
||||||
|
data: {
|
||||||
|
name: `Trigger: ${eventName}`,
|
||||||
|
config: {eventName},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedWorkflow;
|
||||||
|
});
|
||||||
|
|
||||||
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
||||||
if (data.enabled !== undefined || updated.enabled) {
|
if (data.enabled !== undefined || updated.enabled) {
|
||||||
await EventService.invalidateWorkflowCache(projectId);
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also invalidate cache if triggerConfig changed on an enabled workflow
|
||||||
|
if (data.triggerConfig !== undefined && updated.enabled) {
|
||||||
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification if enabled status changed
|
// Send notification if enabled status changed
|
||||||
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
||||||
if (data.enabled) {
|
if (data.enabled) {
|
||||||
|
|||||||
@@ -226,6 +226,22 @@ function CustomNode({
|
|||||||
onMouseLeave={() => setShowActions(false)}
|
onMouseLeave={() => setShowActions(false)}
|
||||||
>
|
>
|
||||||
{/* Action buttons - shown on hover */}
|
{/* Action buttons - shown on hover */}
|
||||||
|
{showActions && data.type === 'TRIGGER' && (
|
||||||
|
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||||
|
<Button
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
data.onEdit?.();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shadow-md"
|
||||||
|
title="Edit trigger settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showActions && data.type !== 'TRIGGER' && (
|
{showActions && data.type !== 'TRIGGER' && (
|
||||||
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||||
<Button
|
<Button
|
||||||
@@ -322,6 +338,14 @@ function CustomNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{data.type === 'TRIGGER' && data.config?.eventName && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
|
<Lightbulb className="h-3 w-3" />
|
||||||
|
<span className="truncate">{data.config.eventName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.type === 'WEBHOOK' && data.config?.url && (
|
{data.type === 'WEBHOOK' && data.config?.url && (
|
||||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
@@ -375,11 +399,23 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
// Define handlers before they are used in useMemo
|
// Define handlers before they are used in useMemo
|
||||||
const handleEditStep = useCallback((stepId: string) => {
|
const handleEditStep = useCallback(
|
||||||
// This will be handled by the parent component
|
(stepId: string) => {
|
||||||
|
// Check if this is a TRIGGER step
|
||||||
|
const step = steps.find(s => s.id === stepId);
|
||||||
|
|
||||||
|
if (step?.type === 'TRIGGER') {
|
||||||
|
// For TRIGGER steps, open workflow settings instead
|
||||||
|
const event = new CustomEvent('workflow-open-settings');
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
} else {
|
||||||
|
// For other steps, open step editor
|
||||||
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
}, []);
|
}
|
||||||
|
},
|
||||||
|
[steps],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeleteStepClick = useCallback((stepId: string) => {
|
const handleDeleteStepClick = useCallback((stepId: string) => {
|
||||||
setStepToDelete(stepId);
|
setStepToDelete(stepId);
|
||||||
|
|||||||
@@ -318,7 +318,12 @@ export default function WorkflowEditorPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateSettings = async (data: {name: string; description?: string}) => {
|
const handleUpdateSettings = async (data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
allowReentry?: boolean;
|
||||||
|
triggerConfig?: {eventName: string};
|
||||||
|
}) => {
|
||||||
try {
|
try {
|
||||||
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${id}`, data);
|
await network.fetch<Workflow, typeof WorkflowSchemas.update>('PATCH', `/workflows/${id}`, data);
|
||||||
toast.success('Workflow updated successfully');
|
toast.success('Workflow updated successfully');
|
||||||
@@ -352,9 +357,15 @@ export default function WorkflowEditorPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenSettingsEvent = () => {
|
||||||
|
setShowSettingsDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('workflow-edit-step', handleEditStepEvent);
|
window.addEventListener('workflow-edit-step', handleEditStepEvent);
|
||||||
|
window.addEventListener('workflow-open-settings', handleOpenSettingsEvent);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('workflow-edit-step', handleEditStepEvent);
|
window.removeEventListener('workflow-edit-step', handleEditStepEvent);
|
||||||
|
window.removeEventListener('workflow-open-settings', handleOpenSettingsEvent);
|
||||||
};
|
};
|
||||||
}, [workflow]);
|
}, [workflow]);
|
||||||
|
|
||||||
@@ -741,21 +752,49 @@ interface SettingsDialogProps {
|
|||||||
workflow: Workflow;
|
workflow: Workflow;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onSave: (data: {name: string; description?: string; allowReentry?: boolean}) => Promise<void>;
|
onSave: (data: {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
allowReentry?: boolean;
|
||||||
|
triggerConfig?: {eventName: string};
|
||||||
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogProps) {
|
function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogProps) {
|
||||||
|
const triggerConfig = workflow.triggerConfig as {eventName?: string} | null;
|
||||||
const [name, setName] = useState(workflow.name);
|
const [name, setName] = useState(workflow.name);
|
||||||
const [description, setDescription] = useState(workflow.description ?? '');
|
const [description, setDescription] = useState(workflow.description ?? '');
|
||||||
const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false);
|
const [allowReentry, setAllowReentry] = useState(workflow.allowReentry ?? false);
|
||||||
|
const [eventName, setEventName] = useState(triggerConfig?.eventName ?? '');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Sync state when workflow changes or dialog opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(workflow.name);
|
||||||
|
setDescription(workflow.description ?? '');
|
||||||
|
setAllowReentry(workflow.allowReentry ?? false);
|
||||||
|
const config = workflow.triggerConfig as {eventName?: string} | null;
|
||||||
|
setEventName(config?.eventName ?? '');
|
||||||
|
}
|
||||||
|
}, [open, workflow]);
|
||||||
|
|
||||||
|
// Fetch available event names
|
||||||
|
const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, {
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSave({name, description: description || undefined, allowReentry});
|
await onSave({
|
||||||
|
name,
|
||||||
|
description: description || undefined,
|
||||||
|
allowReentry,
|
||||||
|
triggerConfig: eventName.trim() ? {eventName: eventName.trim()} : undefined,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -784,6 +823,36 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="eventName">Trigger Event *</Label>
|
||||||
|
{eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? (
|
||||||
|
<Select value={eventName} onValueChange={setEventName} required>
|
||||||
|
<SelectTrigger id="eventName">
|
||||||
|
<SelectValue placeholder="Select an event" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{eventNamesData.eventNames.map(name => (
|
||||||
|
<SelectItem key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id="eventName"
|
||||||
|
type="text"
|
||||||
|
value={eventName}
|
||||||
|
onChange={e => setEventName(e.target.value)}
|
||||||
|
placeholder="e.g., contact.created, email.opened"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
The event that triggers this workflow to start for a contact
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
<div className="flex items-start gap-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||||
<Switch id="allowReentry" checked={allowReentry} onCheckedChange={setAllowReentry} />
|
<Switch id="allowReentry" checked={allowReentry} onCheckedChange={setAllowReentry} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export const WorkflowSchemas = {
|
|||||||
triggerType: z.nativeEnum(WorkflowTriggerType).optional(),
|
triggerType: z.nativeEnum(WorkflowTriggerType).optional(),
|
||||||
triggerConfig: jsonSchema.optional(),
|
triggerConfig: jsonSchema.optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
allowReentry: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
addStep: z.object({
|
addStep: z.object({
|
||||||
type: z.nativeEnum(WorkflowStepType),
|
type: z.nativeEnum(WorkflowStepType),
|
||||||
|
|||||||
Reference in New Issue
Block a user