feat(editor): Add functionality to delete and rename folders (no-changelog) (#13785)

This commit is contained in:
Milorad FIlipović
2025-03-12 11:04:53 +01:00
committed by GitHub
parent 09ebc3adc7
commit e73f618851
21 changed files with 1155 additions and 97 deletions

View File

@@ -161,6 +161,7 @@ jobs:
env:
NODE_OPTIONS: --dns-result-order=ipv4first
CYPRESS_NODE_VIEW_VERSION: 2
N8N_FOLDERS_ENABLED: true
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
E2E_TESTS: true

View File

@@ -0,0 +1,242 @@
import { successToast } from '../pages/notifications';
/**
* Getters
*/
export function getPersonalProjectMenuItem() {
return cy.getByTestId('project-personal-menu-item');
}
export function getOverviewMenuItem() {
return cy.getByTestId('menu-item').contains('Overview');
}
export function getAddResourceDropdown() {
return cy.getByTestId('add-resource');
}
export function getFolderCards() {
return cy.getByTestId('folder-card');
}
export function getFolderCard(name: string) {
return cy.getByTestId('folder-card-name').contains(name).closest('[data-test-id="folder-card"]');
}
export function getAddFolderButton() {
return cy.getByTestId('add-folder-button');
}
export function getListBreadcrumbs() {
return cy.getByTestId('main-breadcrumbs');
}
export function getHomeProjectBreadcrumb() {
return getListBreadcrumbs().findChildByTestId('home-project');
}
export function getVisibleListBreadcrumbs() {
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item');
}
export function getCurrentBreadcrumb() {
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item-current');
}
export function getMainBreadcrumbsEllipsis() {
return getListBreadcrumbs().findChildByTestId('hidden-items-menu');
}
export function getMainBreadcrumbsEllipsisMenuItems() {
return cy
.getByTestId('hidden-items-menu')
.find('span[aria-controls]')
.invoke('attr', 'aria-controls')
.then((popperId) => {
return cy.get(`#${popperId}`).find('li');
});
}
export function getFolderCardBreadCrumbs(folderName: string) {
return getFolderCard(folderName).find('[data-test-id="folder-card-breadcrumbs"]');
}
export function getFolderCardBreadCrumbsEllipsis(folderName: string) {
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="ellipsis"]');
}
export function getFolderCardHomeProjectBreadcrumb(folderName: string) {
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="folder-card-home-project"]');
}
export function getFolderCardCurrentBreadcrumb(folderName: string) {
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="breadcrumbs-item-current"]');
}
export function getOpenHiddenItemsTooltip() {
return cy.getByTestId('hidden-items-tooltip').filter(':visible');
}
export function getListActionsToggle() {
return cy.getByTestId('folder-breadcrumbs-actions');
}
export function getListActionItem(name: string) {
return cy
.getByTestId('folder-breadcrumbs-actions')
.find('span[aria-controls]')
.invoke('attr', 'aria-controls')
.then((popperId) => {
return cy.get(`#${popperId}`).find(`[data-test-id="action-${name}"]`);
});
}
export function getFolderCardActionToggle(folderName: string) {
return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]');
}
export function getFolderCardActionItem(name: string) {
return cy
.getByTestId('folder-card-actions')
.find('span[aria-controls]')
.invoke('attr', 'aria-controls')
.then((popperId) => {
return cy.get(`#${popperId}`).find(`[data-test-id="action-${name}"]`);
});
}
export function getFolderDeleteModal() {
return cy.getByTestId('deleteFolder-modal');
}
export function getDeleteRadioButton() {
return cy.getByTestId('delete-content-radio');
}
export function getConfirmDeleteInput() {
return getFolderDeleteModal().findChildByTestId('delete-data-input').find('input');
}
export function getDeleteFolderModalConfirmButton() {
return getFolderDeleteModal().findChildByTestId('confirm-delete-folder-button');
}
/**
* Actions
*/
export function goToPersonalProject() {
getPersonalProjectMenuItem().click();
}
export function createFolderInsideFolder(childName: string, parentName: string) {
getFolderCard(parentName).click();
createFolderFromListHeaderButton(childName);
}
export function createFolderFromListHeaderButton(folderName: string) {
getAddFolderButton().click();
createNewFolder(folderName);
}
export function createFolderFromProjectHeader(folderName: string) {
getPersonalProjectMenuItem().click();
getAddResourceDropdown().click();
cy.getByTestId('action-folder').click();
createNewFolder(folderName);
}
export function createFolderFromListDropdown(folderName: string) {
getListActionsToggle().click();
getListActionItem('create').click();
createNewFolder(folderName);
}
export function createFolderFromCardActions(parentName: string, folderName: string) {
getFolderCardActionToggle(parentName).click();
getFolderCardActionItem('create').click();
createNewFolder(folderName);
}
export function renameFolderFromListActions(folderName: string, newName: string) {
getFolderCard(folderName).click();
getListActionsToggle().click();
getListActionItem('rename').click();
renameFolder(newName);
}
export function renameFolderFromCardActions(folderName: string, newName: string) {
getFolderCardActionToggle(folderName).click();
getFolderCardActionItem('rename').click();
renameFolder(newName);
}
export function deleteEmptyFolderFromCardDropdown(folderName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderCard(folderName).click();
getListActionsToggle().click();
getListActionItem('delete').click();
cy.wait('@deleteFolder');
successToast().should('contain.text', 'Folder deleted');
}
export function deleteEmptyFolderFromListDropdown(folderName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderCard(folderName).click();
getListActionsToggle().click();
getListActionItem('delete').click();
cy.wait('@deleteFolder');
successToast().should('contain.text', 'Folder deleted');
}
export function deleteFolderWithContentsFromListDropdown(folderName: string) {
getListActionsToggle().click();
getListActionItem('delete').click();
confirmFolderDelete(folderName);
}
export function deleteFolderWithContentsFromCardDropdown(folderName: string) {
getFolderCardActionToggle(folderName).click();
getFolderCardActionItem('delete').click();
confirmFolderDelete(folderName);
}
/**
* Utils
*/
/**
* Types folder name in the prompt and waits for the folder to be created
* @param name
*/
function createNewFolder(name: string) {
cy.intercept('POST', '/rest/projects/**').as('createFolder');
cy.get('[role=dialog]')
.filter(':visible')
.within(() => {
cy.get('input.el-input__inner').type(name, { delay: 50 });
cy.get('button.btn--confirm').click();
});
cy.wait('@createFolder');
successToast().should('exist');
}
function renameFolder(newName: string) {
cy.intercept('PATCH', '/rest/projects/**').as('renameFolder');
cy.get('[role=dialog]')
.filter(':visible')
.within(() => {
cy.get('input.el-input__inner').type('{selectall}');
cy.get('input.el-input__inner').type(newName, { delay: 50 });
cy.get('button.btn--confirm').click();
});
cy.wait('@renameFolder');
successToast().should('exist');
}
function confirmFolderDelete(folderName: string) {
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
getFolderDeleteModal().should('be.visible');
getDeleteRadioButton().click();
getConfirmDeleteInput().should('be.visible');
getConfirmDeleteInput().type(`delete ${folderName}`, { delay: 50 });
getDeleteFolderModalConfirmButton().should('be.enabled').click();
cy.wait('@deleteFolder');
successToast().contains('Folder deleted').should('exist');
}

View File

@@ -0,0 +1,256 @@
import {
createFolderFromCardActions,
createFolderFromListDropdown,
createFolderFromListHeaderButton,
createFolderFromProjectHeader,
createFolderInsideFolder,
deleteEmptyFolderFromCardDropdown,
deleteEmptyFolderFromListDropdown,
deleteFolderWithContentsFromCardDropdown,
deleteFolderWithContentsFromListDropdown,
getAddResourceDropdown,
getCurrentBreadcrumb,
getFolderCard,
getFolderCardActionItem,
getFolderCardActionToggle,
getFolderCardBreadCrumbsEllipsis,
getFolderCardCurrentBreadcrumb,
getFolderCardHomeProjectBreadcrumb,
getFolderCards,
getHomeProjectBreadcrumb,
getListBreadcrumbs,
getMainBreadcrumbsEllipsis,
getMainBreadcrumbsEllipsisMenuItems,
getOpenHiddenItemsTooltip,
getOverviewMenuItem,
getPersonalProjectMenuItem,
getVisibleListBreadcrumbs,
goToPersonalProject,
renameFolderFromCardActions,
renameFolderFromListActions,
} from '../composables/folders';
import { visitWorkflowsPage } from '../composables/workflowsPage';
import { successToast } from '../pages/notifications';
describe('Folders', () => {
before(() => {
cy.resetDatabase();
cy.enableFeature('sharing');
cy.enableFeature('advancedPermissions');
cy.enableFeature('projectRole:admin');
cy.enableFeature('projectRole:editor');
cy.changeQuota('maxTeamProjects', -1);
});
beforeEach(() => {
visitWorkflowsPage();
});
describe('Create and navigate folders', () => {
it('should create folder from the project header', () => {
createFolderFromProjectHeader('My Folder');
getFolderCards().should('have.length.greaterThan', 0);
// Clicking on the success toast should navigate to the folder
successToast().find('a').click();
getCurrentBreadcrumb().should('contain.text', 'My Folder');
});
it('should create folder from the list header button', () => {
goToPersonalProject();
// First create a folder so list appears
createFolderFromProjectHeader('Test 2');
createFolderFromListHeaderButton('My Folder 2');
getFolderCards().should('have.length.greaterThan', 0);
// Clicking on the success toast should navigate to the folder
successToast().find('a').contains('My Folder 2').click();
getCurrentBreadcrumb().should('contain.text', 'My Folder 2');
});
it('should create folder from the list header dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Created from list dropdown');
getFolderCard('Created from list dropdown').should('exist');
getFolderCard('Created from list dropdown').click();
createFolderFromListDropdown('Child Folder');
successToast().should('exist');
getFolderCard('Child Folder').should('exist');
});
it('should create folder from the card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Created from card dropdown');
getFolderCard('Created from card dropdown').should('exist');
createFolderFromCardActions('Created from card dropdown', 'Child Folder');
successToast().should('exist');
// Open parent folder to see the new child folder
getFolderCard('Created from card dropdown').click();
getFolderCard('Child Folder').should('exist');
});
it('should navigate folders using breadcrumbs and dropdown menu', () => {
goToPersonalProject();
createFolderFromProjectHeader('Navigate Test');
// Open folder using menu item
getFolderCardActionToggle('Navigate Test').click();
getFolderCardActionItem('open').click();
getCurrentBreadcrumb().should('contain.text', 'Navigate Test');
// Create new child folder and navigate to it
createFolderFromListHeaderButton('Child Folder');
getFolderCard('Child Folder').should('exist');
getFolderCard('Child Folder').click();
getCurrentBreadcrumb().should('contain.text', 'Child Folder');
// Navigate back to parent folder using breadcrumbs
getVisibleListBreadcrumbs().contains('Navigate Test').click();
getCurrentBreadcrumb().should('contain.text', 'Navigate Test');
// Go back to home project using breadcrumbs
getHomeProjectBreadcrumb().click();
getListBreadcrumbs().should('not.exist');
});
// Creates folders inside folders and also checks breadcrumbs
it('should create multiple levels of folders', () => {
goToPersonalProject();
createFolderFromProjectHeader('Multi-level Test');
createFolderInsideFolder('Child Folder', 'Multi-level Test');
// One level deep:
// - Both main breadcrumbs & card breadcrumbs should only show home project and current folder
getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Multi-level Test');
getFolderCard('Child Folder').should('exist');
getFolderCardHomeProjectBreadcrumb('Child Folder').should('exist');
getFolderCardCurrentBreadcrumb('Child Folder').should('contain.text', 'Multi-level Test');
// No hidden items at this level
getFolderCardBreadCrumbsEllipsis('Child Folder').should('not.exist');
createFolderInsideFolder('Child Folder 2', 'Child Folder');
// Two levels deep:
// - Main breadcrumbs should also show parent folder, without hidden ellipsis
// - Card breadcrumbs should show home project, parent folder, with hidden ellipsis
getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Child Folder');
getVisibleListBreadcrumbs().should('have.length', 1);
getMainBreadcrumbsEllipsis().should('not.exist');
getFolderCardCurrentBreadcrumb('Child Folder 2').should('contain.text', 'Child Folder');
getFolderCardBreadCrumbsEllipsis('Child Folder 2').should('exist');
// Three levels deep:
// - Main breadcrumbs should show parents up to the grandparent folder, with one hidden element
// - Card breadcrumbs should now show two hidden elements
createFolderInsideFolder('Child Folder 3', 'Child Folder 2');
getVisibleListBreadcrumbs().should('have.length', 1);
getMainBreadcrumbsEllipsis().should('exist');
// Clicking on the ellipsis should show hidden element in main breadcrumbs
getMainBreadcrumbsEllipsis().click();
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Multi-level Test');
getMainBreadcrumbsEllipsis().click();
// Card breadcrumbs should show two hidden elements
getFolderCardBreadCrumbsEllipsis('Child Folder 3').should('exist');
// Clicking on the ellipsis should show hidden element in card breadcrumbs
getFolderCardBreadCrumbsEllipsis('Child Folder 3').click();
getOpenHiddenItemsTooltip().should('be.visible');
getOpenHiddenItemsTooltip().should('contain.text', 'Multi-level Test / Child Folder');
});
// Make sure breadcrumbs and folder card show correct info when landing straight on a folder page
it('should correctly render all elements when landing on a folder page', () => {
// Create a few levels of folders
goToPersonalProject();
createFolderFromProjectHeader('Landing Test');
createFolderInsideFolder('Child Folder', 'Landing Test');
createFolderInsideFolder('Child Folder 2', 'Child Folder');
createFolderInsideFolder('Child Folder 3', 'Child Folder 2');
// Reload page to simulate landing on a folder page
cy.reload();
// Main list breadcrumbs should show home project, parent, grandparent, with one hidden element
getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Child Folder 2');
getVisibleListBreadcrumbs().should('have.length', 1);
getVisibleListBreadcrumbs().first().should('contain.text', 'Child Folder');
getMainBreadcrumbsEllipsis().should('exist');
getMainBreadcrumbsEllipsis().click();
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Landing Test');
// Should load child folder card
getFolderCard('Child Folder 3').should('exist');
// Card breadcrumbs should show home project and parent, with two hidden elements
getFolderCardHomeProjectBreadcrumb('Child Folder 3').should('exist');
getFolderCardCurrentBreadcrumb('Child Folder 3').should('contain.text', 'Child Folder 2');
getFolderCardBreadCrumbsEllipsis('Child Folder 3').should('exist');
getFolderCardBreadCrumbsEllipsis('Child Folder 3').click();
getOpenHiddenItemsTooltip().should('be.visible');
getOpenHiddenItemsTooltip().should('contain.text', 'Landing Test / Child Folder');
});
it('should show folders only in projects', () => {
// No folder cards should be shown in the overview page
getOverviewMenuItem().click();
getFolderCards().should('not.exist');
// Option to create folders should not be available in the dropdown
getAddResourceDropdown().click();
cy.getByTestId('action-folder').should('not.exist');
// In personal, we should see previously created folders
getPersonalProjectMenuItem().click();
cy.getByTestId('action-folder').should('exist');
createFolderFromProjectHeader('Personal Folder');
getFolderCards().should('exist');
});
});
describe('Rename and delete folders', () => {
it('should rename folder from main dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Rename Me');
getFolderCard('Rename Me').should('exist');
renameFolderFromListActions('Rename Me', 'Renamed');
getCurrentBreadcrumb().should('contain.text', 'Renamed');
});
it('should rename folder from card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Rename Me 2');
renameFolderFromCardActions('Rename Me 2', 'Renamed 2');
getFolderCard('Renamed 2').should('exist');
});
it('should delete empty folder from card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Delete Me');
getFolderCard('Delete Me').should('exist');
deleteEmptyFolderFromCardDropdown('Delete Me');
});
it('should delete empty folder from main dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('Delete Me 2');
getFolderCard('Delete Me 2').should('exist');
deleteEmptyFolderFromListDropdown('Delete Me 2');
// Since we deleted the current folder, we should be back in the home project
getListBreadcrumbs().should('not.exist');
getPersonalProjectMenuItem().find('li').should('have.class', 'is-active');
});
it('should warn before deleting non-empty folder from list dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('I have children');
createFolderInsideFolder('Child 1', 'I have children');
deleteFolderWithContentsFromListDropdown('I have children');
// Since we deleted the current folder, we should be back in the home project
getListBreadcrumbs().should('not.exist');
getPersonalProjectMenuItem().find('li').should('have.class', 'is-active');
});
// TODO: Once we have a backend endpoint that returns sub-folder count, enable this
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
it.skip('should warn before deleting non-empty folder from card dropdown', () => {
goToPersonalProject();
createFolderFromProjectHeader('I also have family');
createFolderInsideFolder('Child 1', 'I also have family');
// Back to home
getHomeProjectBreadcrumb().click();
getFolderCard('I also have family').should('exist');
deleteFolderWithContentsFromCardDropdown('I also have family');
});
// TODO: Once we have backend endpoint that lists project folders, test transfer when deleting
});
});

View File

@@ -47,6 +47,7 @@ switch (scenario) {
testCommand: 'cypress open',
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 2,
N8N_FOLDERS_ENABLED: true,
},
});
break;
@@ -58,6 +59,7 @@ switch (scenario) {
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 1,
CYPRESS_BASE_URL: 'http://localhost:8080',
N8N_FOLDERS_ENABLED: true,
},
});
break;
@@ -69,6 +71,7 @@ switch (scenario) {
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 2,
CYPRESS_BASE_URL: 'http://localhost:8080',
N8N_FOLDERS_ENABLED: true,
},
});
break;
@@ -82,6 +85,7 @@ switch (scenario) {
testCommand: `cypress run --headless ${specParam}`,
customEnv: {
CYPRESS_NODE_VIEW_VERSION: 2,
N8N_FOLDERS_ENABLED: true,
},
});
break;

View File

@@ -155,9 +155,11 @@ describe('Breadcrumbs', async () => {
},
});
expect(getByTestId('ellipsis')).toBeTruthy();
expect(getByTestId('action-toggle')).toBeTruthy();
expect(getByTestId('hidden-items-menu')).toBeTruthy();
expect(getByTestId('ellipsis')).toHaveClass('disabled');
expect(getByTestId('action-toggle').querySelector('.el-dropdown')).toHaveClass('is-disabled');
expect(getByTestId('hidden-items-menu').querySelector('.el-dropdown')).toHaveClass(
'is-disabled',
);
});
it('does not highlight last item for "highlightLastItem = false" ', () => {

View File

@@ -137,7 +137,7 @@ const handleTooltipClose = () => {
>
<slot name="prepend"></slot>
<ul :class="$style.list">
<li v-if="$slots.prepend && items.length" :class="$style.separator" aria-hidden="true">
<li v-if="$slots.prepend && items.length" :class="$style.separator">
{{ separator }}
</li>
<li
@@ -157,6 +157,7 @@ const handleTooltipClose = () => {
placement="bottom"
size="small"
icon-orientation="horizontal"
data-test-id="hidden-items-menu"
@visible-change="onHiddenMenuVisibleChange"
@action="emitItemSelected"
>
@@ -183,7 +184,7 @@ const handleTooltipClose = () => {
/>
</div>
<div v-else :class="$style.tooltipContent">
<div>
<div data-test-id="hidden-items-tooltip">
<n8n-text>{{ loadedHiddenItems.map((item) => item.label).join(' / ') }}</n8n-text>
</div>
</div>
@@ -191,20 +192,22 @@ const handleTooltipClose = () => {
<span :class="$style['tooltip-ellipsis']">...</span>
</n8n-tooltip>
</li>
<li v-if="showEllipsis" :class="$style.separator" aria-hidden="true">{{ separator }}</li>
<li v-if="showEllipsis" :class="$style.separator">{{ separator }}</li>
<template v-for="(item, index) in items" :key="item.id">
<li
:class="{
[$style.item]: true,
[$style.current]: props.highlightLastItem && index === items.length - 1,
}"
data-test-id="breadcrumbs-item"
:data-test-id="
index === items.length - 1 ? 'breadcrumbs-item-current' : 'breadcrumbs-item'
"
@click.prevent="emitItemSelected(item.id)"
>
<n8n-link v-if="item.href" :href="item.href" theme="text">{{ item.label }}</n8n-link>
<n8n-text v-else>{{ item.label }}</n8n-text>
</li>
<li v-if="index !== items.length - 1" :class="$style.separator" aria-hidden="true">
<li v-if="index !== items.length - 1" :class="$style.separator">
{{ separator }}
</li>
</template>
@@ -232,6 +235,7 @@ const handleTooltipClose = () => {
.list {
display: flex;
list-style: none;
align-items: center;
}
.item.current span {

View File

@@ -7,12 +7,12 @@ exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = fal
<!--v-if-->
<!--v-if-->
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<li class="separator">/</li>
<li class="item" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
@@ -25,12 +25,12 @@ exports[`Breadcrumbs > renders custom separator correctly 1`] = `
<!--v-if-->
<!--v-if-->
<li class="item" 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="separator" aria-hidden="true">➮</li>
<li class="separator">➮</li>
<li class="item" 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="separator" aria-hidden="true">➮</li>
<li class="separator">➮</li>
<li class="item" 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="separator" aria-hidden="true">➮</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<li class="separator">➮</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
@@ -43,12 +43,12 @@ exports[`Breadcrumbs > renders default version correctly 1`] = `
<!--v-if-->
<!--v-if-->
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<li class="separator">/</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
@@ -58,16 +58,16 @@ exports[`Breadcrumbs > renders slots correctly 1`] = `
"<div class="container medium">
<div>[PRE] Custom content</div>
<ul class="list">
<li class="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<!--v-if-->
<!--v-if-->
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<li class="separator">/</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
<div>[POST] Custom content</div>
@@ -81,12 +81,12 @@ exports[`Breadcrumbs > renders small version correctly 1`] = `
<!--v-if-->
<!--v-if-->
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="separator">/</li>
<li class="item" 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="separator" aria-hidden="true">/</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<li class="separator">/</li>
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"

View File

@@ -342,6 +342,7 @@ export type BaseFolderItem = BaseResource & {
createdAt: string;
updatedAt: string;
workflowCount: number;
subFolderCount: number;
parentFolder?: FolderShortInfo;
homeProject?: ProjectSharingData;
sharedWithProjects?: ProjectSharingData[];

View File

@@ -1,5 +1,6 @@
import type {
FolderCreateResponse,
FolderListItem,
FolderTreeResponseItem,
IExecutionResponse,
IExecutionsCurrentSummaryExtended,
@@ -110,3 +111,32 @@ export async function getFolderPath(
`/projects/${projectId}/folders/${folderId}/tree`,
);
}
export async function deleteFolder(
context: IRestApiContext,
projectId: string,
folderId: string,
transferToFolderId?: string,
): Promise<void> {
return await makeRestApiRequest(context, 'DELETE', `/projects/${projectId}/folders/${folderId}`, {
transferToFolderId,
});
}
export async function renameFolder(
context: IRestApiContext,
projectId: string,
folderId: string,
name: string,
): Promise<void> {
return await makeRestApiRequest(context, 'PATCH', `/projects/${projectId}/folders/${folderId}`, {
name,
});
}
export async function getProjectFolders(
context: IRestApiContext,
projectId: string,
): Promise<FolderListItem[]> {
return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/folders`);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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') {

View File

@@ -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>

View File

@@ -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'),

View File

@@ -73,6 +73,7 @@ export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall',

View File

@@ -32,6 +32,7 @@
"generic.annotationData": "Highlighted data",
"generic.any": "Any",
"generic.cancel": "Cancel",
"generic.open": "Open",
"generic.close": "Close",
"generic.confirm": "Confirm",
"generic.create": "Create",
@@ -39,6 +40,7 @@
"generic.filtersApplied": "Filters are currently applied.",
"generic.field": "field",
"generic.fields": "fields",
"generic.folder": "Folder | {count} Folder | {count} Folders",
"generic.learnMore": "Learn more",
"generic.reset": "Reset",
"generic.resetAllFilters": "Reset all filters",
@@ -89,6 +91,7 @@
"generic.variable": "Variable | {count} Variables",
"generic.viewDocs": "View docs",
"generic.workflows": "Workflows",
"generic.rename": "Rename",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@@ -891,12 +894,35 @@
"forms.resourceFiltersDropdown.owner": "Owner",
"forms.resourceFiltersDropdown.owner.placeholder": "Filter by owner",
"forms.resourceFiltersDropdown.reset": "Reset all",
"folders.actions.create": "Create folder",
"folders.actions.create.workflow": "Create workflow",
"folders.actions.moveToFolder": "Move to folder",
"folders.add": "Add folder",
"folders.add.here.message": "Create a new folder here",
"folders.add.to.parent.message": "Create folder in \"{parent}\"",
"folders.add.success.title": "Folder created",
"folders.add.success.message": "<a href=\"{link}\">Open {name}</a> now",
"folders.add.invalidName.message": "Please provide a valid folder name",
"folders.invalidName.message": "Please provide a valid folder name",
"folders.delete.confirm.title": "Delete \"{folderName}\"",
"folders.delete.typeToConfirm": "delete {folderName}",
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
"folders.delete.success.message": "Folder deleted",
"folders.delete.confirmActionAfterDelete": "What should we do with the data in this folder?",
"folder.delete.modal.confirmation": "What should we do with {folders} {workflows} in this folder?",
"folder.count": "the {count} folder | the {count} folders",
"workflow.count": "the {count} workflow | the {count} workflows",
"folder.and.workflow.separator": "and",
"folders.delete.action": "Delete all workflows and subfolders",
"folders.delete.error.message": "Problem while deleting folder",
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
"folders.transfer.action": "Transfer workflows and subfolders to another folder",
"folders.transfer.selectFolder": "Folder to to transfer to",
"folders.transfer.select.placeholder": "Select folder",
"folders.rename.message": "Rename \"{folderName}\"",
"folders.rename.error.title": "Problem renaming folder",
"folders.rename.success.message": "Folder renamed",
"folders.not.found.message": "Folder not found",
"generic.oauth1Api": "OAuth1 API",
"generic.oauth2Api": "OAuth2 API",
"genericHelpers.loading": "Loading",
@@ -2382,6 +2408,7 @@
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
"workflows.empty.easyAI": "Test a ready-to-go AI Agent example",
"workflows.list.easyAI": "Test the power of AI in n8n with this ready-to-go AI Agent Workflow",
"workflows.list.error.fetching": "Error fetching workflows",
"workflows.shareModal.title": "Share '{name}'",
"workflows.shareModal.title.static": "Shared with {projectName}",
"workflows.shareModal.select.placeholder": "Add users...",

View File

@@ -90,6 +90,24 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
return count;
}
const deleteFoldersFromCache = (folderIds: string[]) => {
folderIds.forEach((folderId) => {
delete breadcrumbsCache.value[folderId];
});
};
async function deleteFolder(projectId: string, folderId: string, newParentId?: string) {
await workflowsApi.deleteFolder(rootStore.restApiContext, projectId, folderId, newParentId);
}
async function renameFolder(projectId: string, folderId: string, name: string) {
await workflowsApi.renameFolder(rootStore.restApiContext, projectId, folderId, name);
}
async function fetchProjectFolders(projectId: string) {
return await workflowsApi.getProjectFolders(rootStore.restApiContext, projectId);
}
return {
fetchTotalWorkflowsAndFoldersCount,
breadcrumbsCache,
@@ -98,5 +116,9 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
createFolder,
getFolderPath,
totalWorkflowCount,
deleteFolder,
deleteFoldersFromCache,
renameFolder,
fetchProjectFolders,
};
});

View File

@@ -37,6 +37,7 @@ import {
PROMPT_MFA_CODE_MODAL_KEY,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
DELETE_FOLDER_MODAL_KEY,
} from '@/constants';
import type {
INodeUi,
@@ -66,6 +67,7 @@ import {
import { computed, ref } from 'vue';
import type { Connection } from '@vue-flow/core';
import { useLocalStorage } from '@vueuse/core';
import type { EventBus } from '@n8n/utils/event-bus';
let savedTheme: ThemeOption = 'system';
@@ -156,6 +158,16 @@ export const useUIStore = defineStore(STORES.UI, () => {
activeId: null,
showAuthSelector: false,
} as ModalState,
[DELETE_FOLDER_MODAL_KEY]: {
open: false,
activeId: null,
data: {
content: {
workflowCount: 0,
subFolderCount: 0,
},
},
},
});
const modalStack = ref<string[]>([]);
@@ -477,6 +489,15 @@ export const useUIStore = defineStore(STORES.UI, () => {
openModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
};
const openDeleteFolderModal = (
id: string,
workflowListEventBus: EventBus,
content: { workflowCount: number; subFolderCount: number },
) => {
setActiveId(DELETE_FOLDER_MODAL_KEY, id);
openModalWithData({ name: DELETE_FOLDER_MODAL_KEY, data: { workflowListEventBus, content } });
};
const addActiveAction = (action: string) => {
if (!activeActions.value.includes(action)) {
activeActions.value.push(action);
@@ -648,6 +669,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
deleteNotificationsForView,
resetLastInteractedWith,
setProcessingExecutionResults,
openDeleteFolderModal,
};
});

View File

@@ -311,6 +311,7 @@ describe('Folders', () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
workflowCount: 1,
subFolderCount: 0,
homeProject: {
id: '1',
name: 'Project 1',

View File

@@ -23,6 +23,7 @@ import type {
WorkflowListResource,
WorkflowListItem,
FolderPathItem,
FolderListItem,
} from '@/Interface';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
@@ -126,45 +127,35 @@ const currentFolderId = ref<string | null>(null);
*/
const folderActions = ref<Array<UserAction & { onlyAvailableOn?: 'mainBreadcrumbs' | 'card' }>>([
{
label: 'Open',
label: i18n.baseText('generic.open'),
value: FOLDER_LIST_ITEM_ACTIONS.OPEN,
disabled: false,
onlyAvailableOn: 'card',
},
{
label: 'Create Folder',
label: i18n.baseText('folders.actions.create'),
value: FOLDER_LIST_ITEM_ACTIONS.CREATE,
disabled: false,
},
{
label: 'Create Workflow',
label: i18n.baseText('folders.actions.create.workflow'),
value: FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW,
disabled: false,
},
{
label: 'Rename',
label: i18n.baseText('generic.rename'),
value: FOLDER_LIST_ITEM_ACTIONS.RENAME,
disabled: true,
disabled: false,
},
{
label: 'Move to Folder',
label: i18n.baseText('folders.actions.moveToFolder'),
value: FOLDER_LIST_ITEM_ACTIONS.MOVE,
disabled: true,
},
{
label: 'Change Owner',
value: FOLDER_LIST_ITEM_ACTIONS.CHOWN,
disabled: true,
},
{
label: 'Manage Tags',
value: FOLDER_LIST_ITEM_ACTIONS.TAGS,
disabled: true,
},
{
label: 'Delete',
label: i18n.baseText('generic.delete'),
value: FOLDER_LIST_ITEM_ACTIONS.DELETE,
disabled: true,
disabled: false,
},
]);
const folderCardActions = computed(() =>
@@ -218,6 +209,7 @@ const workflowListResources = computed<Resource[]>(() => {
homeProject: resource.homeProject,
sharedWithProjects: resource.sharedWithProjects,
workflowCount: resource.workflowCount,
subFolderCount: resource.subFolderCount,
parentFolder: resource.parentFolder,
} as FolderResource;
} else {
@@ -280,7 +272,7 @@ const emptyListDescription = computed(() => {
});
/**
* WATCHERS AND STORE SUBSCRIPTIONS
* WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS
*/
watch(
@@ -303,6 +295,21 @@ sourceControlStore.$onAction(({ name, after }) => {
after(async () => await initialize());
});
const onFolderDeleted = async (payload: { folderId: string }) => {
const folderInfo = foldersStore.getCachedFolder(payload.folderId);
foldersStore.deleteFoldersFromCache([payload.folderId, folderInfo?.parentFolder ?? '']);
// If the deleted folder is the current folder, navigate to the parent folder
if (currentFolderId.value === payload.folderId) {
void router.push({
name: VIEWS.PROJECTS_FOLDERS,
params: { projectId: route.params.projectId, folderId: folderInfo?.parentFolder ?? '' },
});
} else {
await fetchWorkflows();
}
};
/**
* LIFE-CYCLE HOOKS
*/
@@ -313,11 +320,13 @@ onMounted(async () => {
workflowListEventBus.on('resource-moved', fetchWorkflows);
workflowListEventBus.on('workflow-duplicated', fetchWorkflows);
workflowListEventBus.on('folder-deleted', onFolderDeleted);
});
onBeforeUnmount(() => {
workflowListEventBus.off('resource-moved', fetchWorkflows);
workflowListEventBus.off('workflow-duplicated', fetchWorkflows);
workflowListEventBus.off('folder-deleted', onFolderDeleted);
});
/**
@@ -328,9 +337,8 @@ onBeforeUnmount(() => {
const initialize = async () => {
loading.value = true;
await setFiltersFromQueryString();
if (!route.params.folderId) {
currentFolderId.value = null;
}
currentFolderId.value = route.params.folderId as string | null;
const [, resourcesPage] = await Promise.all([
usersStore.fetchUsers(),
fetchWorkflows(),
@@ -357,6 +365,18 @@ const fetchWorkflows = async () => {
const homeProjectFilter = filters.value.homeProject || undefined;
const parentFolder = (route.params?.folderId as string) || undefined;
const tags = filters.value.tags.length
? filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name)
: [];
const activeFilter =
filters.value.status === StatusFilter.ALL
? undefined
: filters.value.status === StatusFilter.ACTIVE;
// Only fetch folders if showFolders is enabled and there are not tags or active filter applied
const fetchFolders = showFolders.value && !tags.length && activeFilter === undefined;
try {
const fetchedResources = await workflowsStore.fetchWorkflowsPage(
routeProjectId ?? homeProjectFilter,
currentPage.value,
@@ -364,34 +384,44 @@ const fetchWorkflows = async () => {
currentSort.value,
{
name: filters.value.search || undefined,
active:
filters.value.status === StatusFilter.ALL
? undefined
: filters.value.status === StatusFilter.ACTIVE,
tags: filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name),
active: activeFilter,
tags,
parentFolderId: parentFolder ?? '0', // 0 is the root folder in the API
},
showFolders.value,
fetchFolders,
);
foldersStore.cacheFolders(
fetchedResources
.filter((resource) => resource.resource === 'folder')
.map((r) => ({ id: r.id, name: r.name, parentFolder: r.parentFolder?.id })),
);
const isCurrentFolderCached = foldersStore.breadcrumbsCache[parentFolder ?? ''] !== undefined;
const needToFetchFolderPath = parentFolder && !isCurrentFolderCached && routeProjectId;
if (needToFetchFolderPath) {
breadcrumbsLoading.value = true;
await foldersStore.getFolderPath(routeProjectId, parentFolder);
currentFolderId.value = parentFolder;
breadcrumbsLoading.value = false;
}
await foldersStore.fetchTotalWorkflowsAndFoldersCount(routeProjectId);
delayedLoading.cancel();
workflowsAndFolders.value = fetchedResources;
loading.value = false;
return fetchedResources;
} catch (error) {
toast.showError(error, i18n.baseText('workflows.list.error.fetching'));
// redirect to the project page if the folder is not found
void router.push({ name: VIEWS.PROJECTS_FOLDERS, params: { projectId: routeProjectId } });
return [];
} finally {
delayedLoading.cancel();
loading.value = false;
if (breadcrumbsLoading.value) {
breadcrumbsLoading.value = false;
}
}
};
// Filter and sort methods
@@ -608,6 +638,44 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
workflow.active = data.active;
};
const getFolderListItem = (folderId: string): FolderListItem | undefined => {
return workflowsAndFolders.value.find(
(resource): resource is FolderListItem =>
resource.resource === 'folder' && resource.id === folderId,
);
};
// TODO: This will only count the workflows and folders in the current page
// Check if we need to add counts to /tree endpoint or not show them in modal
const getCurrentFolderWorkflowCount = () => {
const workflows = workflowsAndFolders.value.filter(
(resource): resource is WorkflowListItem => resource.resource === 'workflow',
);
return workflows.length;
};
const getCurrentFolderSubFolderCount = () => {
const folders = workflowsAndFolders.value.filter(
(resource): resource is FolderListItem => resource.resource === 'folder',
);
return folders.length;
};
const getFolderContent = (folderId: string) => {
const folderListItem = getFolderListItem(folderId);
if (!folderListItem) {
toast.showMessage({
title: i18n.baseText('folders.delete.error.message'),
message: i18n.baseText('folders.not.found.message'),
type: 'error',
});
return { workflowCount: 0, subFolderCount: 0 };
}
return {
workflowCount: folderListItem.workflowCount,
subFolderCount: folderListItem.subFolderCount,
};
};
// Breadcrumbs methods
/**
@@ -720,6 +788,16 @@ const onBreadCrumbsAction = async (action: string) => {
case FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW:
addWorkflow();
break;
case FOLDER_LIST_ITEM_ACTIONS.DELETE:
if (!route.params.folderId) return;
const subFolderCount = getCurrentFolderSubFolderCount();
const workflowCount = getCurrentFolderWorkflowCount();
await deleteFolder(route.params.folderId as string, workflowCount, subFolderCount);
break;
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
if (!route.params.folderId) return;
await renameFolder(route.params.folderId as string);
break;
default:
break;
}
@@ -745,6 +823,14 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
query: { projectId: route.params?.projectId, parentFolderId: clickedFolder.id },
});
break;
case FOLDER_LIST_ITEM_ACTIONS.DELETE: {
const content = getFolderContent(clickedFolder.id);
await deleteFolder(clickedFolder.id, content.workflowCount, content.subFolderCount);
break;
}
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
await renameFolder(clickedFolder.id);
break;
default:
break;
}
@@ -758,7 +844,7 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
{
confirmButtonText: i18n.baseText('generic.create'),
cancelButtonText: i18n.baseText('generic.cancel'),
inputErrorMessage: i18n.baseText('folders.add.invalidName.message'),
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
inputValue: '',
inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/,
customClass: 'add-folder-modal',
@@ -774,7 +860,7 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
parent.type === 'folder' ? parent.id : undefined,
);
let newFolderURL = `/projects/${route.params.projectId}`;
let newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
if (newFolder.parentFolder) {
newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
}
@@ -800,8 +886,12 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
homeProject: projectsStore.currentProject as ProjectSharingData,
sharedWithProjects: [],
workflowCount: 0,
subFolderCount: 0,
},
];
foldersStore.cacheFolders([
{ id: newFolder.id, name: newFolder.name, parentFolder: currentFolder.value?.id },
]);
} else {
// Else fetch again with same filters & pagination applied
await fetchWorkflows();
@@ -812,6 +902,39 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
}
};
const renameFolder = async (folderId: string) => {
const folder = foldersStore.getCachedFolder(folderId);
if (!folder || !currentProject.value) return;
const promptResponsePromise = message.prompt(
i18n.baseText('folders.rename.message', { interpolate: { folderName: folder.name } }),
{
confirmButtonText: i18n.baseText('generic.rename'),
cancelButtonText: i18n.baseText('generic.cancel'),
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
inputValue: folder.name,
inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/,
customClass: 'rename-folder-modal',
},
);
const promptResponse = await promptResponsePromise;
if (promptResponse.action === MODAL_CONFIRM) {
const newFolderName = promptResponse.value;
try {
await foldersStore.renameFolder(currentProject.value?.id, folderId, newFolderName);
foldersStore.breadcrumbsCache[folderId].name = newFolderName;
toast.showMessage({
title: i18n.baseText('folders.rename.success.message', {
interpolate: { folderName: newFolderName },
}),
type: 'success',
});
await fetchWorkflows();
} catch (error) {
toast.showError(error, i18n.baseText('folders.rename.error.title'));
}
}
};
const createFolderInCurrent = async () => {
if (!route.params.projectId) return;
const currentParent = currentFolder.value?.name || projectName.value;
@@ -822,6 +945,22 @@ const createFolderInCurrent = async () => {
type: currentFolder.value ? 'folder' : 'project',
});
};
const deleteFolder = async (folderId: string, workflowCount: number, subFolderCount: number) => {
if (subFolderCount || workflowCount) {
uiStore.openDeleteFolderModal(folderId, workflowListEventBus, {
workflowCount,
subFolderCount,
});
} else {
await foldersStore.deleteFolder(route.params.projectId as string, folderId);
toast.showMessage({
title: i18n.baseText('folders.delete.success.message'),
type: 'success',
});
await onFolderDeleted({ folderId });
}
};
</script>
<template>
@@ -903,7 +1042,11 @@ const createFolderInCurrent = async () => {
<div v-if="breadcrumbsLoading" :class="$style['breadcrumbs-loading']">
<n8n-loading :loading="breadcrumbsLoading" :rows="1" variant="p" />
</div>
<div v-else-if="showFolders && currentFolder" :class="$style['breadcrumbs-container']">
<div
v-else-if="showFolders && currentFolder"
:class="$style['breadcrumbs-container']"
data-test-id="main-breadcrumbs"
>
<FolderBreadcrumbs
:breadcrumbs="mainBreadcrumbs"
:actions="mainBreadcrumbsActions"