feat(editor): Add drag n drop support for folders (#14549)

This commit is contained in:
Milorad FIlipović
2025-04-15 16:59:57 +02:00
committed by GitHub
parent 86de2db4f3
commit 57444d3a16
14 changed files with 619 additions and 56 deletions

View File

@@ -387,6 +387,21 @@ export function moveWorkflowToFolder(workflowName: string, folderName: string) {
getMoveToFolderOption(folderName).should('be.visible').click();
getMoveFolderConfirmButton().should('be.enabled').click();
}
export function dragAndDropToFolder(sourceName: string, destinationName: string) {
const draggable = `[data-test-id=draggable]:has([data-resourcename="${sourceName}"])`;
const droppable = `[data-test-id=draggable]:has([data-resourcename="${destinationName}"])`;
cy.get(draggable).trigger('mousedown');
cy.draganddrop(draggable, droppable, { position: 'center' });
}
export function dragAndDropToProjectRoot(sourceName: string) {
const draggable = `[data-test-id=draggable]:has([data-resourcename="${sourceName}"])`;
const droppable = '[data-test-id="home-project"]';
cy.get(draggable).trigger('mousedown');
cy.draganddrop(draggable, droppable, { position: 'center' });
}
/**
* Utils
*/

View File

@@ -14,6 +14,8 @@ import {
deleteEmptyFolderFromListDropdown,
deleteFolderWithContentsFromCardDropdown,
deleteFolderWithContentsFromListDropdown,
dragAndDropToFolder,
dragAndDropToProjectRoot,
getAddResourceDropdown,
getCurrentBreadcrumb,
getFolderCard,
@@ -535,4 +537,61 @@ describe('Folders', () => {
getWorkflowCard('Child - Workflow').findChildByTestId('card-badge').should('exist');
});
});
describe('Drag and drop', () => {
it('should drag and drop folders into folders', () => {
const PROJECT_NAME = 'Drag and Drop Test';
const TARGET_NAME = 'Drag me';
const DESTINATION_NAME = 'Folder Destination';
createNewProject(PROJECT_NAME, { openAfterCreate: true });
createFolderFromProjectHeader(TARGET_NAME);
createFolderFromProjectHeader(DESTINATION_NAME);
dragAndDropToFolder(TARGET_NAME, DESTINATION_NAME);
successToast().should('contain.text', `${TARGET_NAME} has been moved to ${DESTINATION_NAME}`);
// Only one folder card should remain
getFolderCards().should('have.length', 1);
// Check folder in the destination
getFolderCard(DESTINATION_NAME).click();
getFolderCard(TARGET_NAME).should('exist');
});
it('should drag and drop folders into project root breadcrumb', () => {
const PROJECT_NAME = 'Drag to root test';
const TARGET_NAME = 'To Project root';
const PARENT_NAME = 'Parent Folder';
createNewProject(PROJECT_NAME, { openAfterCreate: true });
createFolderFromProjectHeader(PARENT_NAME);
createFolderInsideFolder(TARGET_NAME, PARENT_NAME);
dragAndDropToProjectRoot(TARGET_NAME);
// No folder cards should be shown in the parent folder
getFolderCards().should('not.exist');
successToast().should('contain.text', `${TARGET_NAME} has been moved to ${PROJECT_NAME}`);
// Check folder in the project root
getProjectMenuItem(PROJECT_NAME).click();
getFolderCard(TARGET_NAME).should('exist');
});
it('should drag and drop workflows into folders', () => {
const PROJECT_NAME = 'Drag and Drop WF Test';
const TARGET_NAME = 'Drag me - WF';
const DESTINATION_NAME = 'Workflow Destination';
createNewProject(PROJECT_NAME, { openAfterCreate: true });
createFolderFromProjectHeader(DESTINATION_NAME);
createWorkflowFromProjectHeader(undefined, TARGET_NAME);
getProjectMenuItem(PROJECT_NAME).click();
dragAndDropToFolder(TARGET_NAME, DESTINATION_NAME);
// No workflow cards should be shown in the project root
getWorkflowCards().should('not.exist');
successToast().should('contain.text', `${TARGET_NAME} has been moved to ${DESTINATION_NAME}`);
// Check workflow in the destination
getFolderCard(DESTINATION_NAME).click();
getWorkflowCard(TARGET_NAME).should('exist');
});
});
});

View File

@@ -21,6 +21,7 @@ interface ActionToggleProps {
loadingRowCount?: number;
disabled?: boolean;
popperClass?: string;
trigger?: 'click' | 'hover';
}
defineOptions({ name: 'N8nActionToggle' });
@@ -35,13 +36,17 @@ withDefaults(defineProps<ActionToggleProps>(), {
loadingRowCount: 3,
disabled: false,
popperClass: '',
trigger: 'click',
});
const actionToggleRef = ref<InstanceType<typeof ElDropdown> | null>(null);
const emit = defineEmits<{
action: [value: string];
'visible-change': [value: boolean];
'item-mouseup': [action: UserAction];
}>();
const onCommand = (value: string) => emit('action', value);
const onVisibleChange = (value: boolean) => emit('visible-change', value);
const openActionToggle = (isOpen: boolean) => {
@@ -52,20 +57,29 @@ const openActionToggle = (isOpen: boolean) => {
}
};
const onActionMouseUp = (action: UserAction) => {
emit('item-mouseup', action);
actionToggleRef.value?.handleClose();
};
defineExpose({
openActionToggle,
});
</script>
<template>
<span :class="$style.container" data-test-id="action-toggle" @click.stop.prevent>
<span
:class="['action-toggle', $style.container]"
data-test-id="action-toggle"
@click.stop.prevent
>
<ElDropdown
ref="actionToggleRef"
:placement="placement"
:size="size"
:disabled="disabled"
:popper-class="popperClass"
trigger="click"
:trigger="trigger"
@command="onCommand"
@visible-change="onVisibleChange"
>
@@ -97,6 +111,7 @@ defineExpose({
:command="action.value"
:disabled="action.disabled"
:data-test-id="`action-${action.value}`"
@mouseup="onActionMouseUp(action)"
>
{{ action.label }}
<div :class="$style.iconContainer">

View File

@@ -22,6 +22,7 @@ type Props = {
hiddenItemsTrigger?: 'hover' | 'click';
// Setting this to true will show the ellipsis even if there are no hidden items
pathTruncated?: boolean;
dragActive?: boolean;
};
defineOptions({ name: 'N8nBreadcrumbs' });
@@ -31,6 +32,8 @@ const emit = defineEmits<{
tooltipClosed: [];
hiddenItemsLoadingError: [error: unknown];
itemSelected: [item: PathItem];
itemHover: [item: PathItem];
itemDrop: [item: PathItem];
}>();
const props = withDefaults(defineProps<Props>(), {
@@ -40,8 +43,9 @@ const props = withDefaults(defineProps<Props>(), {
loadingSkeletonRows: 3,
separator: '/',
highlightLastItem: true,
isPathTruncated: false,
pathTruncated: false,
hiddenItemsTrigger: 'click',
dragActive: false,
});
const loadedHiddenItems = ref<PathItem[]>([]);
@@ -120,6 +124,29 @@ const emitItemSelected = (id: string) => {
emit('itemSelected', item);
};
const emitItemHover = (id: string) => {
const item = [...props.items, ...loadedHiddenItems.value].find((i) => i.id === id);
if (!item) {
return;
}
emit('itemHover', item);
};
const onHiddenItemMouseUp = (item: UserAction) => {
const pathItem = [...props.items, ...loadedHiddenItems.value].find((i) => i.id === item.value);
if (!pathItem || !props.dragActive) {
return;
}
emit('itemDrop', pathItem);
};
const onItemMouseUp = (item: PathItem) => {
if (!props.dragActive) {
return;
}
emit('itemDrop', item);
};
const handleTooltipShow = async () => {
emit('tooltipOpened');
await getHiddenItems();
@@ -156,7 +183,11 @@ const handleTooltipClose = () => {
:loading-row-count="loadingSkeletonRows"
:disabled="dropdownDisabled"
:class="$style['action-toggle']"
:popper-class="$style['hidden-items-menu-popper']"
:popper-class="{
[$style['hidden-items-menu-popper']]: true,
[$style.dragging]: dragActive,
}"
:trigger="hiddenItemsTrigger"
theme="dark"
placement="bottom"
size="small"
@@ -164,6 +195,7 @@ const handleTooltipClose = () => {
data-test-id="hidden-items-menu"
@visible-change="onHiddenMenuVisibleChange"
@action="emitItemSelected"
@item-mouseup="onHiddenItemMouseUp"
>
<n8n-text :bold="true" :class="$style.dots">...</n8n-text>
</n8n-action-toggle>
@@ -203,12 +235,17 @@ const handleTooltipClose = () => {
:class="{
[$style.item]: true,
[$style.current]: props.highlightLastItem && index === items.length - 1,
[$style.dragging]: props.dragActive && index !== items.length - 1,
}"
:title="item.label"
:data-test-id="
index === items.length - 1 ? 'breadcrumbs-item-current' : 'breadcrumbs-item'
"
:data-resourceid="item.id"
data-target="folder-breadcrumb-item"
@click.prevent="emitItemSelected(item.id)"
@mouseenter="emitItemHover(item.id)"
@mouseup="index !== items.length - 1 ? onItemMouseUp(item) : {}"
>
<n8n-link v-if="item.href" :href="item.href" theme="text">{{ item.label }}</n8n-link>
<n8n-text v-else>{{ item.label }}</n8n-text>
@@ -225,7 +262,6 @@ const handleTooltipClose = () => {
.container {
display: flex;
align-items: center;
gap: var(--spacing-5xs);
&.small {
display: inline-flex;
@@ -244,6 +280,20 @@ const handleTooltipClose = () => {
align-items: center;
}
.item {
border: var(--border-width-base) var(--border-style-base) transparent;
}
.item.dragging:hover {
border: var(--border-width-base) var(--border-style-base) var(--color-secondary);
border-radius: var(--border-radius-base);
background-color: var(--color-callout-secondary-background);
& a {
cursor: grabbing;
}
}
.item * {
display: block;
white-space: nowrap;
@@ -291,6 +341,11 @@ const handleTooltipClose = () => {
overflow: auto;
}
&.dragging li:hover {
cursor: grabbing;
background-color: var(--color-callout-secondary-background);
}
li {
max-width: var(--spacing-5xl);
display: block;
@@ -385,7 +440,7 @@ const handleTooltipClose = () => {
max-width: var(--spacing-5xl);
}
.item a:hover * {
.item:not(.dragging) a:hover * {
color: var(--color-text-dark);
}

View File

@@ -6,13 +6,13 @@ exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = fal
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<li class="item" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
@@ -24,13 +24,13 @@ exports[`Breadcrumbs > renders custom separator correctly 1`] = `
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">➮</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">➮</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">➮</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
@@ -42,13 +42,13 @@ exports[`Breadcrumbs > renders default version correctly 1`] = `
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
@@ -61,13 +61,13 @@ exports[`Breadcrumbs > renders slots correctly 1`] = `
<li class="separator">/</li>
<!--v-if-->
<!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
<div>[POST] Custom content</div>
@@ -80,13 +80,13 @@ exports[`Breadcrumbs > renders small version correctly 1`] = `
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
<li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
<li class="separator">/</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"

View File

@@ -157,7 +157,7 @@ export interface INodeUpdatePropertiesInformation {
export type XYPosition = [number, number];
export type DraggableMode = 'mapping' | 'panel-resize';
export type DraggableMode = 'mapping' | 'panel-resize' | 'move';
export interface INodeUi extends INode {
position: XYPosition;

View File

@@ -122,6 +122,7 @@ const onDragEnd = () => {
:is="tag"
ref="wrapper"
:class="{ [$style.dragging]: isDragging }"
data-test-id="draggable"
@mousedown="onDragStart"
>
<slot :is-dragging="isDragging"></slot>

View File

@@ -6,6 +6,7 @@ import { ProjectTypes } from '@/types/projects.types';
import type { UserAction } from '@n8n/design-system/types';
import { type PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { computed } from 'vue';
import { useFoldersStore } from '@/stores/folders.store';
type Props = {
actions: UserAction[];
@@ -13,18 +14,24 @@ type Props = {
visibleItems: FolderPathItem[];
hiddenItems: FolderPathItem[];
};
hiddenItemsTrigger?: 'hover' | 'click';
};
defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
hiddenItemsTrigger: 'click',
});
const emit = defineEmits<{
itemSelected: [item: PathItem];
action: [action: string];
itemDrop: [item: PathItem];
projectDrop: [id: string, name: string];
}>();
const i18n = useI18n();
const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore();
const currentProject = computed(() => projectsStore.currentProject);
@@ -35,6 +42,10 @@ const projectName = computed(() => {
return currentProject.value?.name;
});
const isDragging = computed(() => {
return foldersStore.draggedElement !== null;
});
const onItemSelect = (item: PathItem) => {
emit('itemSelected', item);
};
@@ -42,20 +53,61 @@ const onItemSelect = (item: PathItem) => {
const onAction = (action: string) => {
emit('action', action);
};
const onProjectMouseUp = () => {
if (!isDragging.value || !currentProject.value?.name) {
return;
}
emit('projectDrop', currentProject.value.id, currentProject.value.name);
};
const onProjectHover = () => {
if (!isDragging.value || !currentProject.value?.name) {
return;
}
foldersStore.activeDropTarget = {
type: 'project',
id: currentProject.value?.id,
name: currentProject.value?.name,
};
};
const onItemHover = (item: PathItem) => {
if (!isDragging.value) {
return;
}
foldersStore.activeDropTarget = {
type: 'folder',
id: item.id,
name: item.label,
};
};
</script>
<template>
<div :class="$style.container">
<div
:class="{ [$style.container]: true, [$style['dragging']]: isDragging }"
data-test-id="folder-breadcrumbs"
>
<n8n-breadcrumbs
v-if="breadcrumbs.visibleItems"
v-model:drag-active="isDragging"
:items="breadcrumbs.visibleItems"
:highlight-last-item="false"
:path-truncated="breadcrumbs.visibleItems[0].parentFolder"
:hidden-items="breadcrumbs.hiddenItems"
:hidden-items-trigger="props.hiddenItemsTrigger"
data-test-id="folder-list-breadcrumbs"
@item-selected="onItemSelect"
@item-hover="onItemHover"
@item-drop="emit('itemDrop', $event)"
>
<template v-if="currentProject" #prepend>
<div :class="$style['home-project']" data-test-id="home-project">
<div
:class="{ [$style['home-project']]: true, [$style.dragging]: isDragging }"
data-test-id="home-project"
@mouseenter="onProjectHover"
@mouseup="isDragging ? onProjectMouseUp() : null"
>
<n8n-link :to="`/projects/${currentProject.id}`">
<N8nText size="medium" color="text-base">{{ projectName }}</N8nText>
</n8n-link>
@@ -88,6 +140,18 @@ const onAction = (action: string) => {
.home-project {
display: flex;
align-items: center;
padding: var(--spacing-4xs);
border: var(--border-width-base) var(--border-style-base) transparent;
&.dragging:hover {
border: var(--border-width-base) var(--border-style-base) var(--color-secondary);
border-radius: var(--border-radius-base);
background-color: var(--color-callout-secondary-background);
* {
cursor: grabbing;
color: var(--color-text-base);
}
}
&:hover * {
color: var(--color-text-dark);

View File

@@ -8,6 +8,7 @@ exports[`RunDataJson.vue > renders json values properly 1`] = `
<!---->
<div
class=""
data-test-id="draggable"
>
<div

View File

@@ -9,6 +9,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
<!--v-if-->
<div
class="full-height"
data-test-id="draggable"
data-v-d00cba9a=""
>
@@ -919,6 +920,7 @@ exports[`VirtualSchema.vue > renders schema for empty objects and arrays 1`] = `
<!--v-if-->
<div
class="full-height"
data-test-id="draggable"
data-v-d00cba9a=""
>
@@ -1708,6 +1710,7 @@ exports[`VirtualSchema.vue > renders schema in output pane 1`] = `
<!--v-if-->
<div
class="full-height"
data-test-id="draggable"
data-v-d00cba9a=""
>
@@ -2142,6 +2145,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
<!--v-if-->
<div
class="full-height"
data-test-id="draggable"
data-v-d00cba9a=""
>
@@ -2907,6 +2911,7 @@ exports[`VirtualSchema.vue > renders variables and context section 1`] = `
<!--v-if-->
<div
class="full-height"
data-test-id="draggable"
data-v-d00cba9a=""
>

View File

@@ -1,8 +1,25 @@
import { describe, it, expect } from 'vitest';
import { useFolders } from './useFolders';
import { FOLDER_NAME_MAX_LENGTH } from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
vi.mock('@/stores/folders.store', () => ({
useFoldersStore: vi.fn(() => ({
draggedElement: null,
activeDropTarget: null,
})),
}));
describe('useFolders', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
});
afterEach(() => {
vi.clearAllMocks();
});
const { validateFolderName } = useFolders();
describe('validateFolderName', () => {

View File

@@ -5,10 +5,38 @@ import {
ILLEGAL_FOLDER_CHARACTERS,
} from '@/constants';
import { useI18n } from './useI18n';
import { useFoldersStore } from '@/stores/folders.store';
import { computed } from 'vue';
export type DragTarget = {
type: 'folder' | 'workflow';
id: string;
name: string;
};
export type DropTarget = {
type: 'folder' | 'project';
id: string;
name: string;
};
export function isDropTarget(target: DragTarget | DropTarget): target is DropTarget {
return target.type === 'folder' || target.type === 'project';
}
export function isValidResourceType(value: string): value is 'folder' | 'workflow' | 'project' {
return ['folder', 'workflow', 'project'].includes(value);
}
export function useFolders() {
const i18n = useI18n();
const foldersStore = useFoldersStore();
const isDragging = computed(() => {
return foldersStore.draggedElement !== null;
});
function validateFolderName(folderName: string): true | string {
if (FOLDER_NAME_ILLEGAL_CHARACTERS_REGEX.test(folderName)) {
return i18n.baseText('folders.invalidName.invalidCharacters.message', {
@@ -40,7 +68,116 @@ export function useFolders() {
return true;
}
/**
* Drag and drop methods
*/
function onDragStart(el: HTMLElement): void {
const eventTarget = el.closest('[data-target]') as HTMLElement;
if (!eventTarget) return;
const dragTarget = getDragAndDropTarget(eventTarget);
if (!dragTarget) return;
if (dragTarget.type === 'folder' || dragTarget.type === 'workflow') {
foldersStore.draggedElement = {
type: dragTarget.type,
id: dragTarget.id,
name: dragTarget.name,
};
}
}
function onDragEnd(): void {
foldersStore.draggedElement = null;
foldersStore.activeDropTarget = null;
}
function onDragEnter(event: MouseEvent): void {
const eventTarget = event.target as HTMLElement;
if (!eventTarget || !isDragging.value) return;
event.preventDefault();
event.stopPropagation();
const dragTarget = getDragAndDropTarget(eventTarget);
if (!dragTarget || dragTarget.type !== 'folder') return;
foldersStore.activeDropTarget = {
type: dragTarget.type,
id: dragTarget.id,
name: dragTarget.name,
};
}
function resetDropTarget(): void {
foldersStore.activeDropTarget = null;
}
/**
* Get the drag or drop target element from the event target
* @param el
*/
function getDragAndDropTarget(el: HTMLElement): DragTarget | DropTarget | null {
const dragTarget = el.closest('[data-target]') as HTMLElement;
if (!dragTarget) return null;
const targetResource = dragTarget.dataset.target;
const targetId = dragTarget.dataset.resourceid;
const targetName = dragTarget.dataset.resourcename;
if (!targetResource || !targetId || !targetName || !isValidResourceType(targetResource))
return null;
return {
type: targetResource,
id: targetId,
name: targetName,
};
}
/**
* Get the drag or drop target element from the event target and store
* @param event
* @returns {
* draggedResource: DragTarget | undefined;
* dropTarget: DropTarget | undefined;
* }
*/
function handleDrop(event: MouseEvent): {
draggedResource?: DragTarget;
dropTarget?: DropTarget;
} {
const eventTarget = event.target as HTMLElement;
if (!eventTarget || !isDragging.value) return {};
event.preventDefault();
// Save previous dragged element before cancelling the drag event
const draggedResourceId = foldersStore.draggedElement?.id;
const draggedResourceType = foldersStore.draggedElement?.type;
const draggedResourceName = foldersStore.draggedElement?.name;
if (!draggedResourceId || !draggedResourceType || !draggedResourceName) return {};
onDragEnd();
const dropTarget = getDragAndDropTarget(eventTarget);
if (!dropTarget || !isDropTarget(dropTarget)) return {};
return {
draggedResource: {
type: draggedResourceType,
id: draggedResourceId,
name: draggedResourceName,
},
dropTarget: {
type: dropTarget.type,
id: dropTarget.id,
name: dropTarget.name,
},
};
}
return {
validateFolderName,
onDragStart,
onDragEnd,
onDragEnter,
resetDropTarget,
handleDrop,
};
}

View File

@@ -10,6 +10,7 @@ import * as workflowsApi from '@/api/workflows';
import { useRootStore } from './root.store';
import { ref } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { DragTarget, DropTarget } from '@/composables/useFolders';
const BREADCRUMBS_MIN_LOADING_TIME = 300;
@@ -19,6 +20,11 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
const totalWorkflowCount = ref<number>(0);
// Resource that is currently being dragged
const draggedElement = ref<DragTarget | null>(null);
// Only folders and projects can be drop targets
const activeDropTarget = ref<DropTarget | null>(null);
/**
* Cache visited folders so we can build breadcrumbs paths without fetching them from the server
*/
@@ -268,5 +274,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
moveFolder,
fetchFolderContent,
getHiddenBreadcrumbsItems,
draggedElement,
activeDropTarget,
};
});

View File

@@ -9,6 +9,7 @@ import type {
} from '@/components/layouts/ResourcesListLayout.vue';
import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import Draggable from '@/components/Draggable.vue';
import {
EASY_AI_WORKFLOW_EXPERIMENT,
EnterpriseEditionFeature,
@@ -58,6 +59,7 @@ import { debounce } from 'lodash-es';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useFoldersStore } from '@/stores/folders.store';
import type { DragTarget, DropTarget } from '@/composables/useFolders';
import { useFolders } from '@/composables/useFolders';
import { useUsageStore } from '@/stores/usage.store';
import { useInsightsStore } from '@/features/insights/insights.store';
@@ -211,6 +213,14 @@ const currentFolder = computed(() => {
return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null;
});
const isDragging = computed(() => {
return foldersStore.draggedElement !== null;
});
const isDragNDropEnabled = computed(() => {
return !readOnlyEnv.value && hasPermissionToUpdateFolders.value;
});
const hasPermissionToCreateFolders = computed(() => {
if (!currentProject.value) return false;
return getResourcePermissions(currentProject.value.scopes).folder.create === true;
@@ -760,6 +770,91 @@ const getFolderContent = async (folderId: string) => {
}
};
/* Drag and drop methods */
const onFolderCardDrop = async (event: MouseEvent) => {
const { draggedResource, dropTarget } = folderHelpers.handleDrop(event);
if (!draggedResource || !dropTarget) return;
await moveResourceOnDrop(draggedResource, dropTarget);
};
const onBreadCrumbsItemDrop = async (item: PathItem) => {
if (!foldersStore.draggedElement) return;
await moveResourceOnDrop(
{
id: foldersStore.draggedElement.id,
type: foldersStore.draggedElement.type,
name: foldersStore.draggedElement.name,
},
{
id: item.id,
type: 'folder',
name: item.label,
},
);
folderHelpers.onDragEnd();
};
const moveFolderToProjectRoot = async (id: string, name: string) => {
if (!foldersStore.draggedElement) return;
await moveResourceOnDrop(
{
id: foldersStore.draggedElement.id,
type: foldersStore.draggedElement.type,
name: foldersStore.draggedElement.name,
},
{
id,
type: 'project',
name,
},
);
folderHelpers.onDragEnd();
};
/**
* Perform resource move on drop, also handles toast messages and updating the UI
* @param draggedResource
* @param dropTarget
*/
const moveResourceOnDrop = async (draggedResource: DragTarget, dropTarget: DropTarget) => {
if (draggedResource.type === 'folder') {
await moveFolder({
folder: { id: draggedResource.id, name: draggedResource.name },
newParent: { id: dropTarget.id, name: dropTarget.name, type: dropTarget.type },
options: { skipFetch: true, skipNavigation: true },
});
// Remove the dragged folder from the list
workflowsAndFolders.value = workflowsAndFolders.value.filter(
(folder) => folder.id !== draggedResource.id,
);
// Increase the count of the target folder
const targetFolder = getFolderListItem(dropTarget.id);
if (targetFolder) {
targetFolder.subFolderCount += 1;
}
} else if (draggedResource.type === 'workflow') {
await onWorkflowMoved({
workflow: {
id: draggedResource.id,
name: draggedResource.name,
oldParentId: currentFolderId.value ?? '',
},
newParent: { id: dropTarget.id, name: dropTarget.name, type: dropTarget.type },
options: { skipFetch: true },
});
// Remove the dragged workflow from the list
workflowsAndFolders.value = workflowsAndFolders.value.filter(
(workflow) => workflow.id !== draggedResource.id,
);
// Increase the count of the target folder
const targetFolder = getFolderListItem(dropTarget.id);
if (targetFolder) {
targetFolder.workflowCount += 1;
}
}
};
// Breadcrumbs methods
/**
@@ -1087,6 +1182,10 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
const moveFolder = async (payload: {
folder: { id: string; name: string };
newParent: { id: string; name: string; type: 'folder' | 'project' };
options?: {
skipFetch?: boolean;
skipNavigation?: boolean;
};
}) => {
if (!route.params.projectId) return;
try {
@@ -1096,6 +1195,7 @@ const moveFolder = async (payload: {
payload.newParent.type === 'project' ? '0' : payload.newParent.id,
);
const isCurrentFolder = currentFolderId.value === payload.folder.id;
const newFolderURL = router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
params: {
@@ -1103,7 +1203,7 @@ const moveFolder = async (payload: {
folderId: payload.newParent.type === 'project' ? undefined : payload.newParent.id,
},
}).href;
if (isCurrentFolder) {
if (isCurrentFolder && !payload.options?.skipNavigation) {
// If we just moved the current folder, automatically navigate to the new folder
void router.push(newFolderURL);
} else {
@@ -1121,7 +1221,9 @@ const moveFolder = async (payload: {
},
type: 'success',
});
await fetchWorkflows();
if (!payload.options?.skipFetch) {
await fetchWorkflows();
}
}
} catch (error) {
toast.showError(error, i18n.baseText('folders.move.error.title'));
@@ -1150,6 +1252,9 @@ const moveWorkflowToFolder = async (payload: {
const onWorkflowMoved = async (payload: {
workflow: { id: string; name: string; oldParentId: string };
newParent: { id: string; name: string; type: 'folder' | 'project' };
options?: {
skipFetch?: boolean;
};
}) => {
if (!route.params.projectId) return;
try {
@@ -1167,7 +1272,9 @@ const onWorkflowMoved = async (payload: {
parentFolderId: payload.newParent.type === 'project' ? '0' : payload.newParent.id,
versionId: workflowResource?.versionId,
});
await fetchWorkflows();
if (!payload.options?.skipFetch) {
await fetchWorkflows();
}
toast.showToast({
title: i18n.baseText('folders.move.workflow.success.title'),
message: i18n.baseText('folders.move.workflow.success.message', {
@@ -1224,6 +1331,7 @@ const onCreateWorkflowClick = () => {
@update:page-size="setPageSize"
@update:filters="onFiltersUpdated"
@sort="onSortUpdated"
@mouseleave="folderHelpers.resetDropTarget"
>
<template #header>
<ProjectHeader @create-folder="createFolderInCurrent">
@@ -1310,39 +1418,98 @@ const onCreateWorkflowClick = () => {
<FolderBreadcrumbs
:breadcrumbs="mainBreadcrumbs"
:actions="mainBreadcrumbsActions"
:hidden-items-trigger="isDragging ? 'hover' : 'click'"
@item-selected="onBreadcrumbItemClick"
@action="onBreadCrumbsAction"
@item-drop="onBreadCrumbsItemDrop"
@project-drop="moveFolderToProjectRoot"
/>
</div>
</template>
<template #item="{ item: data, index }">
<FolderCard
<Draggable
v-if="(data as FolderResource | WorkflowResource).resourceType === 'folder'"
:key="`folder-${index}`"
:data="data as FolderResource"
:actions="folderCardActions"
:read-only="readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)"
:personal-project="projectsStore.personalProject"
:show-ownership-badge="showCardsBadge"
class="mb-2xs"
@action="onFolderCardAction"
/>
<WorkflowCard
:disabled="!isDragNDropEnabled"
type="move"
target-data-key="folder"
@dragstart="folderHelpers.onDragStart"
@dragend="folderHelpers.onDragEnd"
>
<template #preview>
<N8nCard>
<N8nText tag="h2" bold>
{{ (data as FolderResource).name }}
</N8nText>
</N8nCard>
</template>
<FolderCard
:data="data as FolderResource"
:actions="folderCardActions"
:read-only="
readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)
"
:personal-project="projectsStore.personalProject"
:data-resourceid="(data as FolderResource).id"
:data-resourcename="(data as FolderResource).name"
:class="{
['mb-2xs']: true,
[$style['drag-active']]: isDragging,
[$style.dragging]:
foldersStore.draggedElement?.type === 'folder' &&
foldersStore.draggedElement?.id === (data as FolderResource).id,
[$style['drop-active']]:
foldersStore.activeDropTarget?.id === (data as FolderResource).id,
}"
:show-ownership-badge="showCardsBadge"
data-target="folder"
class="mb-2xs"
@action="onFolderCardAction"
@mouseenter="folderHelpers.onDragEnter"
@mouseup="onFolderCardDrop"
/>
</Draggable>
<Draggable
v-else
:key="`workflow-${index}`"
data-test-id="resources-list-item-workflow"
class="mb-2xs"
:data="data as WorkflowResource"
:workflow-list-event-bus="workflowListEventBus"
:read-only="readOnlyEnv"
:show-ownership-badge="showCardsBadge"
@click:tag="onClickTag"
@workflow:deleted="onWorkflowDeleted"
@workflow:moved="fetchWorkflows"
@workflow:duplicated="fetchWorkflows"
@workflow:active-toggle="onWorkflowActiveToggle"
@action:move-to-folder="moveWorkflowToFolder"
/>
:disabled="!isDragNDropEnabled"
type="move"
target-data-key="workflow"
@dragstart="folderHelpers.onDragStart"
@dragend="folderHelpers.onDragEnd"
>
<template #preview>
<N8nCard>
<N8nText tag="h2" bold>
{{ (data as WorkflowResource).name }}
</N8nText>
</N8nCard>
</template>
<WorkflowCard
data-test-id="resources-list-item-workflow"
:class="{
['mb-2xs']: true,
[$style['drag-active']]: isDragging,
[$style.dragging]:
foldersStore.draggedElement?.type === 'workflow' &&
foldersStore.draggedElement?.id === (data as WorkflowResource).id,
}"
:data="data as WorkflowResource"
:workflow-list-event-bus="workflowListEventBus"
:read-only="readOnlyEnv"
:data-resourceid="(data as WorkflowResource).id"
:data-resourcename="(data as WorkflowResource).name"
:show-ownership-badge="showCardsBadge"
data-target="workflow"
@click:tag="onClickTag"
@workflow:deleted="onWorkflowDeleted"
@workflow:moved="fetchWorkflows"
@workflow:duplicated="fetchWorkflows"
@workflow:active-toggle="onWorkflowActiveToggle"
@action:move-to-folder="moveWorkflowToFolder"
@mouseenter="isDragging ? folderHelpers.resetDropTarget() : {}"
/>
</Draggable>
</template>
<template #empty>
<div class="text-center mt-s" data-test-id="list-empty-state">
@@ -1529,6 +1696,25 @@ const onCreateWorkflowClick = () => {
margin-top: var(--spacing-2xs);
}
}
.drag-active *,
.drag-active :global(.action-toggle) {
cursor: grabbing !important;
}
.dragging {
transition: opacity 0.3s ease;
opacity: 0.3;
border-style: dashed;
pointer-events: none;
}
.drop-active {
:global(.card) {
border-color: var(--color-secondary);
background-color: var(--color-callout-secondary-background);
}
}
</style>
<style lang="scss">