mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Add functionality to delete and rename folders (no-changelog) (#13785)
This commit is contained in:
committed by
GitHub
parent
09ebc3adc7
commit
e73f618851
@@ -0,0 +1,265 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import Modal from '@/components/Modal.vue';
|
||||
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import { useFoldersStore } from '@/stores/folders.store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { FolderListItem } from '@/Interface';
|
||||
|
||||
const props = defineProps<{
|
||||
modalName: string;
|
||||
activeId: string;
|
||||
data: {
|
||||
workflowListEventBus: EventBus;
|
||||
content: {
|
||||
workflowCount: number;
|
||||
subFolderCount: number;
|
||||
};
|
||||
};
|
||||
}>();
|
||||
|
||||
const modalBus = createEventBus();
|
||||
const { showMessage, showError } = useToast();
|
||||
const i18n = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const foldersStore = useFoldersStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const operation = ref('');
|
||||
const deleteConfirmText = ref('');
|
||||
const selectedFolderId = ref<string | null>(null);
|
||||
const projectFolders = ref<FolderListItem[]>([]);
|
||||
|
||||
const currentFolder = computed(() => {
|
||||
return projectFolders.value.find((folder) => folder.id === props.activeId);
|
||||
});
|
||||
|
||||
// Available folders to transfer are all folders except the current folder, it's parent and its children
|
||||
const availableFolders = computed(() => {
|
||||
return projectFolders.value.filter(
|
||||
(folder) =>
|
||||
folder.id !== props.activeId &&
|
||||
folder.parentFolder?.id !== props.activeId &&
|
||||
folder.id !== currentFolder.value?.parentFolder?.id,
|
||||
);
|
||||
});
|
||||
|
||||
const folderToDelete = computed(() => {
|
||||
if (!props.activeId) return null;
|
||||
return foldersStore.breadcrumbsCache[props.activeId];
|
||||
});
|
||||
|
||||
const isPending = computed(() => {
|
||||
return folderToDelete.value ? !folderToDelete.value.name : false;
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
const folderName = folderToDelete.value?.name ?? '';
|
||||
return i18n.baseText('folders.delete.confirm.title', { interpolate: { folderName } });
|
||||
});
|
||||
|
||||
const enabled = computed(() => {
|
||||
if (isPending.value) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
operation.value === 'delete' &&
|
||||
deleteConfirmText.value ===
|
||||
i18n.baseText('folders.delete.typeToConfirm', {
|
||||
interpolate: { folderName: folderToDelete.value?.name ?? '' },
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (operation.value === 'transfer' && selectedFolderId.value) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const folderContentWarningMessage = computed(() => {
|
||||
const folderCount = props.data.content.subFolderCount ?? 0;
|
||||
const workflowCount = props.data.content.workflowCount ?? 0;
|
||||
let folderText = '';
|
||||
let workflowText = '';
|
||||
if (folderCount > 0) {
|
||||
folderText = i18n.baseText('folder.count', { interpolate: { count: folderCount } });
|
||||
}
|
||||
if (workflowCount > 0) {
|
||||
workflowText = i18n.baseText('workflow.count', { interpolate: { count: workflowCount } });
|
||||
}
|
||||
if (folderCount > 0 && workflowCount > 0) {
|
||||
folderText += ` ${i18n.baseText('folder.and.workflow.separator')} `;
|
||||
}
|
||||
return i18n.baseText('folder.delete.modal.confirmation', {
|
||||
interpolate: {
|
||||
folders: folderText,
|
||||
workflows: workflowText,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!enabled.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
loading.value = true;
|
||||
|
||||
await foldersStore.deleteFolder(
|
||||
route.params.projectId as string,
|
||||
props.activeId,
|
||||
selectedFolderId.value ?? undefined,
|
||||
);
|
||||
|
||||
let message = '';
|
||||
if (selectedFolderId.value) {
|
||||
const selectedFolder = availableFolders.value.find(
|
||||
(folder) => folder.id === selectedFolderId.value,
|
||||
);
|
||||
message = i18n.baseText('folders.transfer.confirm.message', {
|
||||
interpolate: { folderName: selectedFolder?.name ?? '' },
|
||||
});
|
||||
}
|
||||
showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('folders.delete.success.message'),
|
||||
message,
|
||||
});
|
||||
props.data.workflowListEventBus.emit('folder-deleted', { folderId: props.activeId });
|
||||
modalBus.emit('close');
|
||||
} catch (error) {
|
||||
showError(error, i18n.baseText('folders.delete.error.message'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
projectFolders.value = await foldersStore.fetchProjectFolders(route.params.projectId as string);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:name="modalName"
|
||||
:title="title"
|
||||
:center="true"
|
||||
width="520"
|
||||
:event-bus="modalBus"
|
||||
@enter="onSubmit"
|
||||
>
|
||||
<template #content>
|
||||
<div>
|
||||
<div v-if="isPending">
|
||||
<n8n-text color="text-base">{{
|
||||
i18n.baseText('folders.delete.confirm.message')
|
||||
}}</n8n-text>
|
||||
</div>
|
||||
<div v-else :class="$style.content">
|
||||
<div>
|
||||
<n8n-text color="text-base">{{ folderContentWarningMessage }}</n8n-text>
|
||||
</div>
|
||||
<el-radio
|
||||
v-model="operation"
|
||||
data-test-id="transfer-content-radio"
|
||||
label="transfer"
|
||||
@update:model-value="operation = 'transfer'"
|
||||
>
|
||||
<n8n-text color="text-dark">{{ i18n.baseText('folders.transfer.action') }}</n8n-text>
|
||||
</el-radio>
|
||||
<div v-if="operation === 'transfer'" :class="$style.optionInput">
|
||||
<n8n-text color="text-dark">{{
|
||||
i18n.baseText('folders.transfer.selectFolder')
|
||||
}}</n8n-text>
|
||||
<N8nSelect
|
||||
v-model="selectedFolderId"
|
||||
option-label="name"
|
||||
option-value="id"
|
||||
:placeholder="i18n.baseText('folders.transfer.selectFolder')"
|
||||
>
|
||||
<N8nOption
|
||||
v-for="folder in availableFolders"
|
||||
:key="folder.id"
|
||||
:value="folder.id"
|
||||
:label="folder.name"
|
||||
>
|
||||
<div :class="$style['folder-select-item']">
|
||||
<n8n-icon icon="folder" />
|
||||
<span> {{ folder.name }}</span>
|
||||
</div>
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
<el-radio
|
||||
v-model="operation"
|
||||
data-test-id="delete-content-radio"
|
||||
label="delete"
|
||||
@update:model-value="operation = 'delete'"
|
||||
>
|
||||
<n8n-text color="text-dark">{{ i18n.baseText('folders.delete.action') }}</n8n-text>
|
||||
</el-radio>
|
||||
<div
|
||||
v-if="operation === 'delete'"
|
||||
:class="$style.optionInput"
|
||||
data-test-id="delete-data-input"
|
||||
>
|
||||
<n8n-input-label
|
||||
:label="
|
||||
i18n.baseText('folders.delete.confirmation.message', {
|
||||
interpolate: { folderName: folderToDelete?.name ?? '' },
|
||||
})
|
||||
"
|
||||
>
|
||||
<n8n-input
|
||||
v-model="deleteConfirmText"
|
||||
data-test-id="delete-data-input"
|
||||
:placeholder="
|
||||
i18n.baseText('folders.delete.typeToConfirm', {
|
||||
interpolate: { folderName: folderToDelete?.name ?? '' },
|
||||
})
|
||||
"
|
||||
/>
|
||||
</n8n-input-label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<n8n-button
|
||||
:loading="loading"
|
||||
:disabled="!enabled"
|
||||
:label="i18n.baseText('generic.delete')"
|
||||
float="right"
|
||||
data-test-id="confirm-delete-folder-button"
|
||||
@click="onSubmit"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.content {
|
||||
padding-bottom: var(--spacing-2xs);
|
||||
> * {
|
||||
margin-bottom: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
.innerContent {
|
||||
> * {
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
.optionInput {
|
||||
padding-left: var(--spacing-l);
|
||||
}
|
||||
|
||||
.folder-select-item {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -51,11 +51,11 @@ const onAction = (action: string) => {
|
||||
:highlight-last-item="false"
|
||||
:path-truncated="breadcrumbs.visibleItems[0].parentFolder"
|
||||
:hidden-items="breadcrumbs.hiddenItems"
|
||||
data-test-id="folder-card-breadcrumbs"
|
||||
data-test-id="folder-list-breadcrumbs"
|
||||
@item-selected="onItemSelect"
|
||||
>
|
||||
<template v-if="currentProject" #prepend>
|
||||
<div :class="$style['home-project']">
|
||||
<div :class="$style['home-project']" data-test-id="home-project">
|
||||
<n8n-link :to="`/projects/${currentProject.id}`">
|
||||
<N8nText size="large" color="text-base">{{ projectName }}</N8nText>
|
||||
</n8n-link>
|
||||
|
||||
@@ -32,7 +32,8 @@ const DEFAULT_FOLDER: FolderResource = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
resourceType: 'folder',
|
||||
readOnly: false,
|
||||
workflowCount: 0,
|
||||
workflowCount: 2,
|
||||
subFolderCount: 2,
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'Project 1',
|
||||
@@ -51,6 +52,7 @@ const PARENT_FOLDER: FolderResource = {
|
||||
resourceType: 'folder',
|
||||
readOnly: false,
|
||||
workflowCount: 0,
|
||||
subFolderCount: 0,
|
||||
homeProject: {
|
||||
id: '1',
|
||||
name: 'Project 1',
|
||||
@@ -100,11 +102,26 @@ describe('FolderCard', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
|
||||
expect(getByTestId('folder-card-name')).toHaveTextContent(DEFAULT_FOLDER.name);
|
||||
expect(getByTestId('folder-card-workflow-count')).toHaveTextContent('0');
|
||||
expect(getByTestId('folder-card-workflow-count')).toHaveTextContent('2');
|
||||
expect(getByTestId('folder-card-folder-count')).toHaveTextContent('2');
|
||||
expect(getByTestId('folder-card-last-updated')).toHaveTextContent('Last updated just now');
|
||||
expect(getByTestId('folder-card-created')).toHaveTextContent('Created just now');
|
||||
});
|
||||
|
||||
it('should not render workflow & folder count if they are 0', () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
data: {
|
||||
...DEFAULT_FOLDER,
|
||||
workflowCount: 0,
|
||||
subFolderCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(queryByTestId('folder-card-workflow-count')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('folder-card-folder-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render breadcrumbs with personal folder', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
|
||||
|
||||
@@ -80,7 +80,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div data-test-id="folder-card">
|
||||
<router-link :to="cardUrl" @click="() => emit('folderOpened', { folder: props.data })">
|
||||
<n8n-card :class="$style.card">
|
||||
<template #prepend>
|
||||
@@ -99,12 +99,24 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
<template #footer>
|
||||
<div :class="$style['card-footer']">
|
||||
<n8n-text
|
||||
v-if="data.workflowCount > 0"
|
||||
size="small"
|
||||
color="text-light"
|
||||
:class="[$style['info-cell'], $style['info-cell--workflow-count']]"
|
||||
data-test-id="folder-card-folder-count"
|
||||
>
|
||||
{{
|
||||
i18n.baseText('generic.workflow', { interpolate: { count: data.workflowCount } })
|
||||
}}
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
v-if="data.subFolderCount > 0"
|
||||
size="small"
|
||||
color="text-light"
|
||||
:class="[$style['info-cell'], $style['info-cell--workflow-count']]"
|
||||
data-test-id="folder-card-workflow-count"
|
||||
>
|
||||
{{ data.workflowCount }} {{ i18n.baseText('generic.workflows') }}
|
||||
{{ i18n.baseText('generic.folder', { interpolate: { count: data.subFolderCount } }) }}
|
||||
</n8n-text>
|
||||
<n8n-text
|
||||
size="small"
|
||||
@@ -140,7 +152,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
@item-selected="onBreadcrumbsItemClick"
|
||||
>
|
||||
<template v-if="data.homeProject" #prepend>
|
||||
<div :class="$style['home-project']">
|
||||
<div :class="$style['home-project']" data-test-id="folder-card-home-project">
|
||||
<n8n-link :to="`/projects/${data.homeProject.id}`">
|
||||
<ProjectIcon :icon="projectIcon" :border-less="true" size="mini" />
|
||||
<n8n-text size="small" :compact="true" :bold="true" color="text-base">
|
||||
@@ -207,7 +219,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3xs);
|
||||
color: var(--color-text-dark);
|
||||
color: var(—color-text-base);
|
||||
}
|
||||
|
||||
@include mixins.breakpoint('sm-and-down') {
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
PROJECT_MOVE_RESOURCE_MODAL,
|
||||
PROMPT_MFA_CODE_MODAL_KEY,
|
||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
DELETE_FOLDER_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from '@/components/AboutModal.vue';
|
||||
@@ -280,5 +281,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
||||
<CommunityPlusEnrollmentModal :modal-name="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="DELETE_FOLDER_MODAL_KEY">
|
||||
<template #default="{ modalName, activeId, data }">
|
||||
<DeleteFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,6 +57,7 @@ const showSettings = computed(
|
||||
|
||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||
const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled);
|
||||
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
|
||||
|
||||
const ACTION_TYPES = {
|
||||
WORKFLOW: 'workflow',
|
||||
@@ -84,7 +85,7 @@ const menu = computed(() => {
|
||||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
|
||||
},
|
||||
];
|
||||
if (isFoldersFeatureEnabled.value) {
|
||||
if (isFoldersFeatureEnabled.value && !isOverviewPage.value) {
|
||||
items.push({
|
||||
value: ACTION_TYPES.FOLDER,
|
||||
label: i18n.baseText('projects.header.create.folder'),
|
||||
|
||||
Reference in New Issue
Block a user