diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml
index 4f394325a8..8826f980c5 100644
--- a/.github/workflows/e2e-reusable.yml
+++ b/.github/workflows/e2e-reusable.yml
@@ -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
diff --git a/cypress/composables/folders.ts b/cypress/composables/folders.ts
new file mode 100644
index 0000000000..7d604e71bc
--- /dev/null
+++ b/cypress/composables/folders.ts
@@ -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');
+}
diff --git a/cypress/e2e/49-folders.cy.ts b/cypress/e2e/49-folders.cy.ts
new file mode 100644
index 0000000000..69d5a6a4ea
--- /dev/null
+++ b/cypress/e2e/49-folders.cy.ts
@@ -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
+ });
+});
diff --git a/cypress/scripts/run-e2e.js b/cypress/scripts/run-e2e.js
index c7f9ccf749..545b88bcf9 100755
--- a/cypress/scripts/run-e2e.js
+++ b/cypress/scripts/run-e2e.js
@@ -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;
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/BreadCrumbs.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/BreadCrumbs.test.ts
index 1c3cf8b8e2..5efa713356 100644
--- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/BreadCrumbs.test.ts
+++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/BreadCrumbs.test.ts
@@ -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" ', () => {
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue
index a7ecd21628..626a6ef4e4 100644
--- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue
+++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue
@@ -137,7 +137,7 @@ const handleTooltipClose = () => {
>
- -
+
-
{{ separator }}
- {
placement="bottom"
size="small"
icon-orientation="horizontal"
+ data-test-id="hidden-items-menu"
@visible-change="onHiddenMenuVisibleChange"
@action="emitItemSelected"
>
@@ -183,7 +184,7 @@ const handleTooltipClose = () => {
/>
-
+
{{ loadedHiddenItems.map((item) => item.label).join(' / ') }}
@@ -191,20 +192,22 @@ const handleTooltipClose = () => {
...
-
- {{ separator }}
+
- {{ separator }}
-
{{ item.label }}
{{ item.label }}
- -
+
-
{{ separator }}
@@ -232,6 +235,7 @@ const handleTooltipClose = () => {
.list {
display: flex;
list-style: none;
+ align-items: center;
}
.item.current span {
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap
index 5e200d417a..ea99152a03 100644
--- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap
+++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap
@@ -7,12 +7,12 @@ exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = fal
- Folder 1
-
- /
+
- /
- Folder 2
-
- /
+
- /
- Folder 3
-
- /
-
- Current
+
- /
+
- Current
"
@@ -25,12 +25,12 @@ exports[`Breadcrumbs > renders custom separator correctly 1`] = `
- Folder 1
- - ➮
+ - ➮
- Folder 2
- - ➮
+ - ➮
- Folder 3
- - ➮
- - Current
+ - ➮
+ - Current
"
@@ -43,12 +43,12 @@ exports[`Breadcrumbs > renders default version correctly 1`] = `
Folder 1
- /
+ /
Folder 2
- /
+ /
Folder 3
- /
- Current
+ /
+ Current
"
@@ -58,16 +58,16 @@ exports[`Breadcrumbs > renders slots correctly 1`] = `
"
[PRE] Custom content
[POST] Custom content
@@ -81,12 +81,12 @@ exports[`Breadcrumbs > renders small version correctly 1`] = `
Folder 1
-
/
+
/
Folder 2
-
/
+
/
Folder 3
-
/
-
Current
+
/
+
Current
"
diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts
index ceb7b1579e..73fca7fc74 100644
--- a/packages/frontend/editor-ui/src/Interface.ts
+++ b/packages/frontend/editor-ui/src/Interface.ts
@@ -342,6 +342,7 @@ export type BaseFolderItem = BaseResource & {
createdAt: string;
updatedAt: string;
workflowCount: number;
+ subFolderCount: number;
parentFolder?: FolderShortInfo;
homeProject?: ProjectSharingData;
sharedWithProjects?: ProjectSharingData[];
diff --git a/packages/frontend/editor-ui/src/api/workflows.ts b/packages/frontend/editor-ui/src/api/workflows.ts
index 7ac2e74d34..5e97417ccd 100644
--- a/packages/frontend/editor-ui/src/api/workflows.ts
+++ b/packages/frontend/editor-ui/src/api/workflows.ts
@@ -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 {
+ return await makeRestApiRequest(context, 'DELETE', `/projects/${projectId}/folders/${folderId}`, {
+ transferToFolderId,
+ });
+}
+
+export async function renameFolder(
+ context: IRestApiContext,
+ projectId: string,
+ folderId: string,
+ name: string,
+): Promise {
+ return await makeRestApiRequest(context, 'PATCH', `/projects/${projectId}/folders/${folderId}`, {
+ name,
+ });
+}
+
+export async function getProjectFolders(
+ context: IRestApiContext,
+ projectId: string,
+): Promise {
+ return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/folders`);
+}
diff --git a/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue b/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue
new file mode 100644
index 0000000000..72fca8e7fb
--- /dev/null
+++ b/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue
@@ -0,0 +1,265 @@
+
+
+
+
+
+
+
+ {{
+ i18n.baseText('folders.delete.confirm.message')
+ }}
+
+
+
+ {{ folderContentWarningMessage }}
+
+
+ {{ i18n.baseText('folders.transfer.action') }}
+
+
+
{{
+ i18n.baseText('folders.transfer.selectFolder')
+ }}
+
+
+
+
+ {{ folder.name }}
+
+
+
+
+
+ {{ i18n.baseText('folders.delete.action') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/Folders/FolderBreadcrumbs.vue b/packages/frontend/editor-ui/src/components/Folders/FolderBreadcrumbs.vue
index 1c8e0fbe0a..57ee9a8a14 100644
--- a/packages/frontend/editor-ui/src/components/Folders/FolderBreadcrumbs.vue
+++ b/packages/frontend/editor-ui/src/components/Folders/FolderBreadcrumbs.vue
@@ -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"
>
-
+
{{ projectName }}
diff --git a/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts b/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts
index bf19b9a2fc..7a66fce008 100644
--- a/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts
+++ b/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts
@@ -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();
diff --git a/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue b/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue
index 16f3ed3e5c..951fcf0220 100644
--- a/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue
+++ b/packages/frontend/editor-ui/src/components/Folders/FolderCard.vue
@@ -80,7 +80,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
-
+
emit('folderOpened', { folder: props.data })">
@@ -99,12 +99,24 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
+ {{
+ i18n.baseText('generic.workflow', { interpolate: { count: data.workflowCount } })
+ }}
+
+
- {{ data.workflowCount }} {{ i18n.baseText('generic.workflows') }}
+ {{ i18n.baseText('generic.folder', { interpolate: { count: data.subFolderCount } }) }}
{
@item-selected="onBreadcrumbsItemClick"
>
-
+
@@ -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') {
diff --git a/packages/frontend/editor-ui/src/components/Modals.vue b/packages/frontend/editor-ui/src/components/Modals.vue
index 41b41466b8..b4b40ed275 100644
--- a/packages/frontend/editor-ui/src/components/Modals.vue
+++ b/packages/frontend/editor-ui/src/components/Modals.vue
@@ -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';
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue
index de581b1be0..d83692f438 100644
--- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue
+++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue
@@ -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'),
diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts
index 37fad8ca31..94a1316d0f 100644
--- a/packages/frontend/editor-ui/src/constants.ts
+++ b/packages/frontend/editor-ui/src/constants.ts
@@ -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',
diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json
index cea30929b8..da435e5fd6 100644
--- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json
+++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json
@@ -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": "
Open {name} 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...",
diff --git a/packages/frontend/editor-ui/src/stores/folders.store.ts b/packages/frontend/editor-ui/src/stores/folders.store.ts
index 3bce50fb88..f49eed5168 100644
--- a/packages/frontend/editor-ui/src/stores/folders.store.ts
+++ b/packages/frontend/editor-ui/src/stores/folders.store.ts
@@ -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,
};
});
diff --git a/packages/frontend/editor-ui/src/stores/ui.store.ts b/packages/frontend/editor-ui/src/stores/ui.store.ts
index 67979e287b..24da0f0e72 100644
--- a/packages/frontend/editor-ui/src/stores/ui.store.ts
+++ b/packages/frontend/editor-ui/src/stores/ui.store.ts
@@ -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
([]);
@@ -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,
};
});
diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts
index 0a2550ee32..f7a7f356e7 100644
--- a/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts
+++ b/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts
@@ -311,6 +311,7 @@ describe('Folders', () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
workflowCount: 1,
+ subFolderCount: 0,
homeProject: {
id: '1',
name: 'Project 1',
diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/views/WorkflowsView.vue
index 6699cb4261..ca9cd474e0 100644
--- a/packages/frontend/editor-ui/src/views/WorkflowsView.vue
+++ b/packages/frontend/editor-ui/src/views/WorkflowsView.vue
@@ -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(null);
*/
const folderActions = ref>([
{
- 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(() => {
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,41 +365,63 @@ const fetchWorkflows = async () => {
const homeProjectFilter = filters.value.homeProject || undefined;
const parentFolder = (route.params?.folderId as string) || undefined;
- const fetchedResources = await workflowsStore.fetchWorkflowsPage(
- routeProjectId ?? homeProjectFilter,
- currentPage.value,
- pageSize.value,
- 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),
- parentFolderId: parentFolder ?? '0', // 0 is the root folder in the API
- },
- showFolders.value,
- );
- 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);
+ 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;
- delayedLoading.cancel();
- workflowsAndFolders.value = fetchedResources;
- loading.value = false;
- return fetchedResources;
+ // 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,
+ pageSize.value,
+ currentSort.value,
+ {
+ name: filters.value.search || undefined,
+ active: activeFilter,
+ tags,
+ parentFolderId: parentFolder ?? '0', // 0 is the root folder in the API
+ },
+ 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);
+ breadcrumbsLoading.value = false;
+ }
+
+ await foldersStore.fetchTotalWorkflowsAndFoldersCount(routeProjectId);
+
+ workflowsAndFolders.value = fetchedResources;
+ 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 });
+ }
+};
@@ -903,7 +1042,11 @@ const createFolderInCurrent = async () => {
-