mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 18:41:14 +00:00
feat(editor): Add drag n drop support for folders (#14549)
This commit is contained in:
committed by
GitHub
parent
86de2db4f3
commit
57444d3a16
@@ -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