mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Add drag n drop support for folders (#14549)
This commit is contained in:
committed by
GitHub
parent
86de2db4f3
commit
57444d3a16
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,6 +8,7 @@ exports[`RunDataJson.vue > renders json values properly 1`] = `
|
||||
<!---->
|
||||
<div
|
||||
class=""
|
||||
data-test-id="draggable"
|
||||
>
|
||||
|
||||
<div
|
||||
|
||||
@@ -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=""
|
||||
>
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user