From 9771f56a8b26fab84e87635fa2d7a13717f533c8 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Fri, 29 May 2026 20:58:41 +0200 Subject: [PATCH] feat: workflows drag and drop enhancements --- .../workflows/[workflowId]/+page.svelte | 83 ++++++++++++++----- .../[workflowId]/WorkflowStepCard.svelte | 40 ++++----- 2 files changed, 78 insertions(+), 45 deletions(-) diff --git a/web/src/routes/(user)/workflows/[workflowId]/+page.svelte b/web/src/routes/(user)/workflows/[workflowId]/+page.svelte index 962c7d601f..7c0d1656eb 100644 --- a/web/src/routes/(user)/workflows/[workflowId]/+page.svelte +++ b/web/src/routes/(user)/workflows/[workflowId]/+page.svelte @@ -6,6 +6,7 @@ import WorkflowTriggerPicker from '$lib/modals/WorkflowTriggerPicker.svelte'; import { Route } from '$lib/route'; import { getWorkflowActions, handleUpdateWorkflow } from '$lib/services/workflow.service'; + import { generateId } from '$lib/utils/generate-id'; import { getTriggerDescription, getTriggerName } from '$lib/utils/workflow'; import type { WorkflowResponseDto, WorkflowUpdateDto } from '@immich/sdk'; import { @@ -44,6 +45,7 @@ } from '@mdi/js'; import { cloneDeep, isEqual } from 'lodash-es'; import { t } from 'svelte-i18n'; + import { flip } from 'svelte/animate'; import type { PageData } from './$types'; import WorkflowJsonEditor from './WorkflowJsonEditor.svelte'; import WorkflowStepCard from './WorkflowStepCard.svelte'; @@ -62,12 +64,13 @@ let { data }: Props = $props(); let { id, enabled, name, description, trigger } = $derived(data.workflow); - let steps = $state(data.workflow.steps); + let steps = $state(data.workflow.steps.map((step) => ({ ...step, id: generateId() }))); let savedWorkflow = $state(cloneDeep(data.workflow)); let allowNavigation = $state(false); let isShowingNavigationDialog = $state(false); let isSaving = $state(false); let editMode = $state('visual'); + let dragSourceId: string | undefined; const workflowSummary = $derived({ name, description, trigger, steps }); const workflowJsonContent = $derived({ name, description, enabled, trigger, steps }); @@ -77,20 +80,23 @@ name !== savedWorkflow.name || description !== savedWorkflow.description || !isEqual(trigger, savedWorkflow.trigger) || - !isEqual(steps, savedWorkflow.steps), + !isEqual( + steps.map(({ id: _, ...step }) => step), + savedWorkflow.steps, + ), ); const handleAddStep = async () => { const step = await modalManager.show(WorkflowAddStepModal, { trigger }); if (step) { - steps.push(step); + steps.push({ ...step, id: generateId() }); } }; const handleInsertStep = async (index: number) => { const step = await modalManager.show(WorkflowAddStepModal, { trigger }); if (step) { - steps = [...steps.slice(0, index), step, ...steps.slice(index)]; + steps = [...steps.slice(0, index), { ...step, id: generateId() }, ...steps.slice(index)]; } }; @@ -102,20 +108,53 @@ const result = await modalManager.show(WorkflowEditStepModal, { trigger, step: cloneDeep(step) }); if (result) { - steps[index] = result; + steps[index] = { ...result, id: generateId() }; } }; - const handleDrop = (index: number, event: DragEvent) => { - if (!event.dataTransfer) { + const handleDragEnd = (event: DragEvent) => { + if (!event.dataTransfer || !dragSourceId) { return; } - const from = Number(event.dataTransfer.getData('text/plain')); + const ghostIndex = steps.findIndex(({ id }) => id === 'ghost'); + if (ghostIndex === -1) { + return; + } + const from = steps.findIndex(({ id }) => id === dragSourceId); const next = [...steps]; - const [moved] = next.splice(from, 1); - next.splice(index, 0, moved); + const [step] = next.splice(from, 1); + next[ghostIndex > from ? ghostIndex - 1 : ghostIndex] = step; + steps = next; + dragSourceId = undefined; + }; + + const handleDragOver = (index: number, event: DragEvent, boundingRect: DOMRect) => { + if (!event.dataTransfer || !dragSourceId) { + return; + } + + const fromIndex = steps.findIndex(({ id }) => dragSourceId === id); + const ghostIndex = steps.findIndex(({ id }) => id === 'ghost'); + const shiftedIndex = event.clientY > boundingRect.top + boundingRect.height / 2 ? index + 1 : index; + + if (index === fromIndex || shiftedIndex === fromIndex) { + if (ghostIndex !== -1) { + steps.splice(ghostIndex, 1); + } + return; + } + + if ( + (ghostIndex !== -1 && Math.abs(shiftedIndex - ghostIndex) <= (ghostIndex > shiftedIndex ? 0 : 1)) || + Math.abs(shiftedIndex - fromIndex) <= (fromIndex > shiftedIndex ? 0 : 1) + ) { + return; + } + + const next = steps.filter(({ id }) => id !== 'ghost'); + next.splice(shiftedIndex, 0, { ...steps[fromIndex], id: 'ghost' }); steps = next; }; @@ -131,7 +170,7 @@ name = content.name; description = content.description; trigger = content.trigger; - steps = cloneDeep(content.steps); + steps = cloneDeep(content.steps).map((step) => ({ ...step, id: generateId() })); }; const onClose = () => goto(Route.workflows()); @@ -344,15 +383,19 @@ - {#each steps as step, index (step.method + index)} - + {#each steps as step, index (step.id)} +
+ (dragSourceId = event.dataTransfer?.getData('text/plain'))} + /> +
{/each}