diff --git a/cypress/composables/folders.ts b/cypress/composables/folders.ts index 7d604e71bc..56085c0a3e 100644 --- a/cypress/composables/folders.ts +++ b/cypress/composables/folders.ts @@ -22,6 +22,31 @@ export function getFolderCards() { export function getFolderCard(name: string) { return cy.getByTestId('folder-card-name').contains(name).closest('[data-test-id="folder-card"]'); } + +export function getWorkflowCards() { + return cy.getByTestId('resources-list-item-workflow'); +} + +export function getWorkflowCard(name: string) { + return cy + .getByTestId('workflow-card-name') + .contains(name) + .closest('[data-test-id="resources-list-item-workflow"]'); +} + +export function getWorkflowCardActions(name: string) { + return getWorkflowCard(name).find('[data-test-id="workflow-card-actions"]'); +} + +export function getWorkflowCardActionItem(workflowName: string, actionName: string) { + return getWorkflowCardActions(workflowName) + .find('span[aria-controls]') + .invoke('attr', 'aria-controls') + .then((popperId) => { + return cy.get(`#${popperId}`).find(`[data-test-id="action-${actionName}"]`); + }); +} + export function getAddFolderButton() { return cy.getByTestId('add-folder-button'); } @@ -34,6 +59,10 @@ export function getHomeProjectBreadcrumb() { return getListBreadcrumbs().findChildByTestId('home-project'); } +export function getListBreadcrumbItem(name: string) { + return getListBreadcrumbs().findChildByTestId('breadcrumbs-item').contains(name); +} + export function getVisibleListBreadcrumbs() { return getListBreadcrumbs().findChildByTestId('breadcrumbs-item'); } @@ -94,13 +123,14 @@ 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') +export function getFolderCardActionItem(folderName: string, actionName: string) { + return getFolderCard(folderName) + .findChildByTestId('folder-card-actions') + .filter(':visible') .find('span[aria-controls]') .invoke('attr', 'aria-controls') .then((popperId) => { - return cy.get(`#${popperId}`).find(`[data-test-id="action-${name}"]`); + return cy.get(`#${popperId}`).find(`[data-test-id="action-${actionName}"]`); }); } @@ -108,10 +138,18 @@ export function getFolderDeleteModal() { return cy.getByTestId('deleteFolder-modal'); } +export function getMoveFolderModal() { + return cy.getByTestId('moveFolder-modal'); +} + export function getDeleteRadioButton() { return cy.getByTestId('delete-content-radio'); } +export function getTransferContentRadioButton() { + return cy.getByTestId('transfer-content-radio'); +} + export function getConfirmDeleteInput() { return getFolderDeleteModal().findChildByTestId('delete-data-input').find('input'); } @@ -119,6 +157,61 @@ export function getConfirmDeleteInput() { export function getDeleteFolderModalConfirmButton() { return getFolderDeleteModal().findChildByTestId('confirm-delete-folder-button'); } + +export function getProjectEmptyState() { + return cy.getByTestId('list-empty-state'); +} + +export function getFolderEmptyState() { + return cy.getByTestId('empty-folder-container'); +} + +export function getProjectMenuItem(name: string) { + if (name.toLowerCase() === 'personal') { + return getPersonalProjectMenuItem(); + } + return cy.getByTestId('project-menu-item').contains(name); +} + +export function getMoveToFolderDropdown() { + return cy.getByTestId('move-to-folder-dropdown'); +} + +export function getMoveToFolderOption(name: string) { + return cy.getByTestId('move-to-folder-option').contains(name); +} + +export function getMoveToFolderInput() { + return getMoveToFolderDropdown().find('input'); +} + +export function getEmptyFolderDropdownMessage(text: string) { + return cy.get('.el-select-dropdown__empty').contains(text); +} + +export function getMoveFolderConfirmButton() { + return cy.getByTestId('confirm-move-folder-button'); +} + +export function getMoveWorkflowModal() { + return cy.getByTestId('moveFolder-modal'); +} + +export function getWorkflowCardBreadcrumbs(workflowName: string) { + return getWorkflowCard(workflowName).find('[data-test-id="workflow-card-breadcrumbs"]'); +} + +export function getWorkflowCardBreadcrumbsEllipsis(workflowName: string) { + return getWorkflowCardBreadcrumbs(workflowName).find('[data-test-id="ellipsis"]'); +} + +export function getNewFolderNameInput() { + return cy.get('.add-folder-modal').filter(':visible').find('input.el-input__inner'); +} + +export function getNewFolderModalErrorMessage() { + return cy.get('.el-message-box__errormsg').filter(':visible'); +} /** * Actions */ @@ -136,8 +229,46 @@ export function createFolderFromListHeaderButton(folderName: string) { createNewFolder(folderName); } +export function createWorkflowFromEmptyState(workflowName?: string) { + getFolderEmptyState().find('button').contains('Create Workflow').click(); + if (workflowName) { + cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, { + delay: 50, + }); + } + cy.getByTestId('workflow-save-button').click(); + successToast().should('exist'); +} + +export function createWorkflowFromProjectHeader(folderName?: string, workflowName?: string) { + cy.getByTestId('add-resource-workflow').click(); + if (workflowName) { + cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, { + delay: 50, + }); + } + cy.getByTestId('workflow-save-button').click(); + if (folderName) { + successToast().should( + 'contain.text', + `Workflow successfully created in "Personal", within "${folderName}"`, + ); + } +} + +export function createWorkflowFromListDropdown(workflowName?: string) { + getListActionsToggle().click(); + getListActionItem('create_workflow').click(); + if (workflowName) { + cy.getByTestId('workflow-name-input').type(`{selectAll}{backspace}${workflowName}`, { + delay: 50, + }); + } + cy.getByTestId('workflow-save-button').click(); + successToast().should('exist'); +} + export function createFolderFromProjectHeader(folderName: string) { - getPersonalProjectMenuItem().click(); getAddResourceDropdown().click(); cy.getByTestId('action-folder').click(); createNewFolder(folderName); @@ -151,7 +282,7 @@ export function createFolderFromListDropdown(folderName: string) { export function createFolderFromCardActions(parentName: string, folderName: string) { getFolderCardActionToggle(parentName).click(); - getFolderCardActionItem('create').click(); + getFolderCardActionItem(parentName, 'create').click(); createNewFolder(folderName); } @@ -164,7 +295,7 @@ export function renameFolderFromListActions(folderName: string, newName: string) export function renameFolderFromCardActions(folderName: string, newName: string) { getFolderCardActionToggle(folderName).click(); - getFolderCardActionItem('rename').click(); + getFolderCardActionItem(folderName, 'rename').click(); renameFolder(newName); } @@ -194,9 +325,63 @@ export function deleteFolderWithContentsFromListDropdown(folderName: string) { export function deleteFolderWithContentsFromCardDropdown(folderName: string) { getFolderCardActionToggle(folderName).click(); - getFolderCardActionItem('delete').click(); + getFolderCardActionItem(folderName, 'delete').click(); confirmFolderDelete(folderName); } + +export function deleteAndTransferFolderContentsFromCardDropdown( + folderName: string, + destinationName: string, +) { + getFolderCardActionToggle(folderName).click(); + getFolderCardActionItem(folderName, 'delete').click(); + deleteFolderAndMoveContents(folderName, destinationName); +} + +export function deleteAndTransferFolderContentsFromListDropdown(destinationName: string) { + getListActionsToggle().click(); + getListActionItem('delete').click(); + getCurrentBreadcrumb() + .find('span') + .invoke('text') + .then((currentFolderName) => { + deleteFolderAndMoveContents(currentFolderName, destinationName); + }); +} + +export function createNewProject(projectName: string, options: { openAfterCreate?: boolean } = {}) { + cy.getByTestId('universal-add').should('exist').click(); + cy.getByTestId('navigation-menu-item').contains('Project').click(); + cy.getByTestId('project-settings-name-input').type(projectName, { delay: 50 }); + cy.getByTestId('project-settings-save-button').click(); + successToast().should('exist'); + if (options.openAfterCreate) { + getProjectMenuItem(projectName).click(); + } +} + +export function moveFolderFromFolderCardActions(folderName: string, destinationName: string) { + getFolderCardActionToggle(folderName).click(); + getFolderCardActionItem(folderName, 'move').click(); + moveFolder(folderName, destinationName); +} + +export function moveFolderFromListActions(folderName: string, destinationName: string) { + getFolderCard(folderName).click(); + getListActionsToggle().click(); + getListActionItem('move').click(); + moveFolder(folderName, destinationName); +} + +export function moveWorkflowToFolder(workflowName: string, folderName: string) { + getWorkflowCardActions(workflowName).click(); + getWorkflowCardActionItem(workflowName, 'moveToFolder').click(); + getMoveFolderModal().should('be.visible'); + getMoveToFolderDropdown().click(); + getMoveToFolderInput().type(folderName, { delay: 50 }); + getMoveToFolderOption(folderName).should('be.visible').click(); + getMoveFolderConfirmButton().should('be.enabled').click(); +} /** * Utils */ @@ -240,3 +425,34 @@ function confirmFolderDelete(folderName: string) { cy.wait('@deleteFolder'); successToast().contains('Folder deleted').should('exist'); } + +function deleteFolderAndMoveContents(folderName: string, destinationName: string) { + cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder'); + getFolderDeleteModal().should('be.visible'); + getFolderDeleteModal().find('h1').first().contains(`Delete "${folderName}"`); + getTransferContentRadioButton().should('be.visible').click(); + getMoveToFolderDropdown().click(); + getMoveToFolderInput().type(destinationName); + getMoveToFolderOption(destinationName).click(); + getDeleteFolderModalConfirmButton().should('be.enabled').click(); + cy.wait('@deleteFolder'); + successToast().should('contain.text', `Data transferred to "${destinationName}"`); +} + +function moveFolder(folderName: string, destinationName: string) { + cy.intercept('PATCH', '/rest/projects/**').as('moveFolder'); + getMoveFolderModal().should('be.visible'); + getMoveFolderModal().find('h1').first().contains(`Move "${folderName}" to another folder`); + getMoveToFolderDropdown().click(); + // Try to find current folder in the dropdown + getMoveToFolderInput().type(folderName, { delay: 50 }); + // Should not be available + getEmptyFolderDropdownMessage('No folders found').should('exist'); + // Select destination folder + getMoveToFolderInput().type(`{selectall}{backspace}${destinationName}`, { + delay: 50, + }); + getMoveToFolderOption(destinationName).should('be.visible').click(); + getMoveFolderConfirmButton().should('be.enabled').click(); + cy.wait('@moveFolder'); +} diff --git a/cypress/composables/ndv.ts b/cypress/composables/ndv.ts index 36ae10669b..1423801a5e 100644 --- a/cypress/composables/ndv.ts +++ b/cypress/composables/ndv.ts @@ -105,11 +105,13 @@ export function getNodeOutputHint() { } export function getWorkflowCards() { - return cy.getByTestId('resources-list-item'); + return cy.getByTestId('resources-list-item-workflow'); } export function getWorkflowCard(workflowName: string) { - return getWorkflowCards().contains(workflowName).parents('[data-test-id="resources-list-item"]'); + return getWorkflowCards() + .contains(workflowName) + .parents('[data-test-id="resources-list-item-workflow"]'); } export function getWorkflowCardContent(workflowName: string) { diff --git a/cypress/e2e/30-editor-after-route-changes.cy.ts b/cypress/e2e/30-editor-after-route-changes.cy.ts index 89c64e1156..6b069dfbf1 100644 --- a/cypress/e2e/30-editor-after-route-changes.cy.ts +++ b/cypress/e2e/30-editor-after-route-changes.cy.ts @@ -21,7 +21,7 @@ const switchBetweenEditorAndWorkflowlist = () => { cy.getByTestId('menu-item').first().click(); cy.wait(['@getUsers', '@getWorkflows', '@getActiveWorkflows', '@getProjects']); - cy.getByTestId('resources-list-item').first().click(); + cy.getByTestId('resources-list-item-workflow').first().click(); workflowPage.getters.canvasNodes().first().should('be.visible'); workflowPage.getters.canvasNodes().last().should('be.visible'); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index b6dcb449da..1288173907 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -514,7 +514,7 @@ describe('Projects', { disableAutoLogin: true }, () => { workflowsPage.getters.workflowCards().should('have.length', 3); workflowsPage.getters .workflowCards() - .filter(':has(.n8n-badge:contains("Project"))') + .filter(':has([data-test-id="workflow-card-breadcrumbs"]:contains("Project"))') .should('have.length', 2); workflowsPage.getters.workflowCardActions('Workflow in Home project').click(); workflowsPage.getters.workflowMoveButton().click(); diff --git a/cypress/e2e/49-folders.cy.ts b/cypress/e2e/49-folders.cy.ts index 8591daaaa0..5208c5bb20 100644 --- a/cypress/e2e/49-folders.cy.ts +++ b/cypress/e2e/49-folders.cy.ts @@ -4,6 +4,12 @@ import { createFolderFromListHeaderButton, createFolderFromProjectHeader, createFolderInsideFolder, + createNewProject, + createWorkflowFromEmptyState, + createWorkflowFromListDropdown, + createWorkflowFromProjectHeader, + deleteAndTransferFolderContentsFromCardDropdown, + deleteAndTransferFolderContentsFromListDropdown, deleteEmptyFolderFromCardDropdown, deleteEmptyFolderFromListDropdown, deleteFolderWithContentsFromCardDropdown, @@ -14,14 +20,27 @@ import { getFolderCardActionItem, getFolderCardActionToggle, getFolderCards, + getFolderEmptyState, getHomeProjectBreadcrumb, + getListBreadcrumbItem, getListBreadcrumbs, getMainBreadcrumbsEllipsis, getMainBreadcrumbsEllipsisMenuItems, + getNewFolderModalErrorMessage, + getNewFolderNameInput, getOverviewMenuItem, getPersonalProjectMenuItem, + getProjectEmptyState, + getProjectMenuItem, getVisibleListBreadcrumbs, + getWorkflowCard, + getWorkflowCardBreadcrumbs, + getWorkflowCardBreadcrumbsEllipsis, + getWorkflowCards, goToPersonalProject, + moveFolderFromFolderCardActions, + moveFolderFromListActions, + moveWorkflowToFolder, renameFolderFromCardActions, renameFolderFromListActions, } from '../composables/folders'; @@ -44,6 +63,7 @@ describe('Folders', () => { describe('Create and navigate folders', () => { it('should create folder from the project header', () => { + getPersonalProjectMenuItem().click(); createFolderFromProjectHeader('My Folder'); getFolderCards().should('have.length.greaterThan', 0); // Clicking on the success toast should navigate to the folder @@ -51,6 +71,33 @@ describe('Folders', () => { getCurrentBreadcrumb().should('contain.text', 'My Folder'); }); + it('should not allow illegal folder names', () => { + // Validation logic is thoroughly tested in unit tests + // Here we just make sure everything is working in the full UI + const ILLEGAL_CHARACTERS_NAME = 'hello['; + const ONLY_DOTS_NAME = '...'; + const REGULAR_NAME = 'My Folder'; + + getPersonalProjectMenuItem().click(); + getAddResourceDropdown().click(); + cy.getByTestId('action-folder').click(); + getNewFolderNameInput().type(ILLEGAL_CHARACTERS_NAME, { delay: 50 }); + getNewFolderModalErrorMessage().should( + 'contain.text', + 'Folder name cannot contain the following characters', + ); + getNewFolderNameInput().clear(); + getNewFolderNameInput().type(ONLY_DOTS_NAME, { delay: 50 }); + getNewFolderModalErrorMessage().should( + 'contain.text', + 'Folder name cannot contain only dots', + ); + getNewFolderNameInput().clear(); + getNewFolderModalErrorMessage().should('contain.text', 'Folder name cannot be empty'); + getNewFolderNameInput().type(REGULAR_NAME, { delay: 50 }); + getNewFolderModalErrorMessage().should('not.exist'); + }); + it('should create folder from the list header button', () => { goToPersonalProject(); // First create a folder so list appears @@ -78,9 +125,9 @@ describe('Folders', () => { 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(); + // Should be automatically navigated to the new folder getFolderCard('Child Folder').should('exist'); + getCurrentBreadcrumb().should('contain.text', 'Created from card dropdown'); }); it('should navigate folders using breadcrumbs and dropdown menu', () => { @@ -88,7 +135,7 @@ describe('Folders', () => { createFolderFromProjectHeader('Navigate Test'); // Open folder using menu item getFolderCardActionToggle('Navigate Test').click(); - getFolderCardActionItem('open').click(); + getFolderCardActionItem('Navigate Test', 'open').click(); getCurrentBreadcrumb().should('contain.text', 'Navigate Test'); // Create new child folder and navigate to it createFolderFromListHeaderButton('Child Folder'); @@ -165,12 +212,72 @@ describe('Folders', () => { // In personal, we should see previously created folders getPersonalProjectMenuItem().click(); + getAddResourceDropdown().click(); cy.getByTestId('action-folder').should('exist'); createFolderFromProjectHeader('Personal Folder'); getFolderCards().should('exist'); }); }); + describe('Empty State', () => { + it('should show project empty state when no folders exist', () => { + createNewProject('Test empty project', { openAfterCreate: true }); + getProjectEmptyState().should('exist'); + }); + + it('should toggle folder empty state correctly', () => { + createNewProject('Test empty folder', { openAfterCreate: true }); + createFolderFromProjectHeader('My Folder'); + getProjectEmptyState().should('not.exist'); + getFolderCard('My Folder').should('exist'); + getFolderCard('My Folder').click(); + getFolderEmptyState().should('exist'); + // Create a new workflow from the empty state + createWorkflowFromEmptyState('My Workflow'); + // Toast should inform that the workflow was created in the folder + successToast().should( + 'contain.text', + 'Workflow successfully created in "Test empty folder", within "My Folder"', + ); + // Go back to the folder + getProjectMenuItem('Test empty folder').click(); + getFolderCard('My Folder').should('exist'); + getFolderCard('My Folder').click(); + // Should not show empty state anymore + getFolderEmptyState().should('not.exist'); + getWorkflowCards().should('have.length.greaterThan', 0); + // Also when filtering and there are no results, empty state CTA should not show + cy.getByTestId('resources-list-search').type('non-existing', { delay: 20 }); + getWorkflowCards().should('not.exist'); + getFolderEmptyState().should('not.exist'); + // But there should be a message saying that no results were found + cy.getByTestId('resources-list-empty').should('exist'); + }); + }); + + describe('Create workflows inside folders', () => { + it('should create workflows in folders in all supported ways', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Workflows go here'); + // 1. From empty state + getFolderCard('Workflows go here').should('exist').click(); + createWorkflowFromEmptyState('Created from empty state'); + goToPersonalProject(); + getFolderCard('Workflows go here').click(); + getWorkflowCard('Created from empty state').should('exist'); + // 2. From the project header + createWorkflowFromProjectHeader('Workflows go here', 'Created from project header'); + goToPersonalProject(); + getFolderCard('Workflows go here').click(); + getWorkflowCard('Created from project header').should('exist'); + // 3. From list breadcrumbs + createWorkflowFromListDropdown('Created from list breadcrumbs'); + goToPersonalProject(); + getFolderCard('Workflows go here').click(); + getWorkflowCard('Created from list breadcrumbs').should('exist'); + }); + }); + describe('Rename and delete folders', () => { it('should rename folder from main dropdown', () => { goToPersonalProject(); @@ -224,6 +331,175 @@ describe('Folders', () => { deleteFolderWithContentsFromCardDropdown('I also have family'); }); - // TODO: Once we have backend endpoint that lists project folders, test transfer when deleting + it('should transfer contents when deleting non-empty folder - from card dropdown', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move my contents'); + createFolderFromProjectHeader('Destination'); + createFolderInsideFolder('Child 1', 'Move my contents'); + getHomeProjectBreadcrumb().click(); + getFolderCard('Move my contents').should('exist'); + deleteAndTransferFolderContentsFromCardDropdown('Move my contents', 'Destination'); + getFolderCard('Destination').click(); + // Should show the contents of the moved folder + getFolderCard('Child 1').should('exist'); + }); + + it('should transfer contents when deleting non-empty folder - from list breadcrumbs', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me too'); + createFolderFromProjectHeader('Destination 2'); + createFolderInsideFolder('Child 1', 'Move me too'); + deleteAndTransferFolderContentsFromListDropdown('Destination 2'); + getFolderCard('Destination').click(); + // Should show the contents of the moved folder + getFolderCard('Child 1').should('exist'); + }); + }); + + describe('Move folders and workflows', () => { + it('should move empty folder to another folder - from folder card action', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me - I am empty'); + createFolderFromProjectHeader('Destination 3'); + moveFolderFromFolderCardActions('Move me - I am empty', 'Destination 3'); + getFolderCard('Destination 3').click(); + getFolderCard('Move me - I am empty').should('exist'); + getFolderCard('Move me - I am empty').click(); + getFolderEmptyState().should('exist'); + successToast().should('contain.text', 'Move me - I am empty has been moved to Destination 3'); + // Breadcrumbs should show the destination folder + getListBreadcrumbItem('Destination 3').should('exist'); + }); + + it('should move folder with contents to another folder - from folder card action', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me - I have family'); + createFolderFromProjectHeader('Destination 4'); + // Create a workflow and a folder inside the folder + createFolderInsideFolder('Child 1', 'Move me - I have family'); + createWorkflowFromProjectHeader('Move me - I have family'); + goToPersonalProject(); + // Move the folder + moveFolderFromFolderCardActions('Move me - I have family', 'Destination 4'); + successToast().should( + 'contain.text', + 'Move me - I have family has been moved to Destination 4', + ); + // Go to destination folder and check if contents are there + getFolderCard('Destination 4').click(); + // Moved folder should be there + getFolderCard('Move me - I have family').should('exist').click(); + // Both the workflow and the folder should be there + getFolderCards().should('have.length', 1); + getWorkflowCards().should('have.length', 1); + // Breadcrumbs should show the destination folder + getListBreadcrumbItem('Destination 4').should('exist'); + }); + + it('should move empty folder to another folder - from list breadcrumbs', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me too - I am empty'); + createFolderFromProjectHeader('Destination 5'); + moveFolderFromListActions('Move me too - I am empty', 'Destination 5'); + // Since we moved the current folder, we should be in the destination folder + getCurrentBreadcrumb().should('contain.text', 'Destination 5'); + }); + + it('should move folder with contents to another folder - from list dropdown', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Move me - I have family 2'); + createFolderFromProjectHeader('Destination 6'); + // Create a workflow and a folder inside the folder + createFolderInsideFolder('Child 1', 'Move me - I have family 2'); + createWorkflowFromProjectHeader('Move me - I have family 2'); + // Navigate back to folder + goToPersonalProject(); + getFolderCard('Move me - I have family 2').should('exist'); + // Move the folder + moveFolderFromListActions('Move me - I have family 2', 'Destination 6'); + // Since we moved the current folder, we should be in the destination folder + getCurrentBreadcrumb().should('contain.text', 'Destination 6'); + // Moved folder should be there + getFolderCard('Move me - I have family 2').should('exist').click(); + // After navigating to the moved folder, both the workflow and the folder should be there + getFolderCards().should('have.length', 1); + getWorkflowCards().should('have.length', 1); + // Breadcrumbs should show the destination folder + getListBreadcrumbItem('Destination 6').should('exist'); + }); + + it('should move folder to project root - from folder card action', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Test parent'); + createFolderInsideFolder('Move me to root', 'Test parent'); + moveFolderFromFolderCardActions('Move me to root', 'Personal'); + // Parent folder should be empty + getFolderEmptyState().should('exist'); + // Child folder should be in the root + goToPersonalProject(); + getFolderCard('Move me to root').should('exist'); + // Navigate to the moved folder and check breadcrumbs + getFolderCard('Move me to root').click(); + getHomeProjectBreadcrumb().should('contain.text', 'Personal'); + getListBreadcrumbs().findChildByTestId('breadcrumbs-item').should('not.exist'); + getCurrentBreadcrumb().should('contain.text', 'Move me to root'); + }); + + it('should move workflow from project root to folder', () => { + goToPersonalProject(); + createWorkflowFromProjectHeader(undefined, 'Move me'); + goToPersonalProject(); + createFolderFromProjectHeader('Workflow destination'); + moveWorkflowToFolder('Move me', 'Workflow destination'); + successToast().should('contain.text', 'Move me has been moved to Workflow destination'); + // Navigate to the destination folder + getFolderCard('Workflow destination').click(); + // Moved workflow should be there + getWorkflowCards().should('have.length', 1); + getWorkflowCard('Move me').should('exist'); + }); + + it('should move workflow to another folder', () => { + goToPersonalProject(); + createFolderFromProjectHeader('Moving workflow from here'); + createFolderFromProjectHeader('Moving workflow to here'); + getFolderCard('Moving workflow from here').click(); + createWorkflowFromProjectHeader(undefined, 'Move me'); + goToPersonalProject(); + getFolderCard('Moving workflow from here').click(); + getWorkflowCard('Move me').should('exist'); + moveWorkflowToFolder('Move me', 'Moving workflow to here'); + // Now folder should be empty + getFolderEmptyState().should('exist'); + // Navigate to the destination folder + getHomeProjectBreadcrumb().click(); + getFolderCard('Moving workflow to here').click(); + // Moved workflow should be there + getWorkflowCards().should('have.length', 1); + getWorkflowCard('Move me').should('exist'); + }); + }); + + describe('Workflow card breadcrumbs', () => { + it('should correctly show workflow card breadcrumbs', () => { + createNewProject('Test workflow breadcrumbs', { openAfterCreate: true }); + createFolderFromProjectHeader('Parent Folder'); + createFolderInsideFolder('Child Folder', 'Parent Folder'); + getFolderCard('Child Folder').click(); + createFolderFromListHeaderButton('Child Folder 2'); + getFolderCard('Child Folder 2').click(); + createWorkflowFromEmptyState('Breadcrumbs Test'); + // Go to overview page + getOverviewMenuItem().click(); + getWorkflowCard('Breadcrumbs Test').should('exist'); + getWorkflowCardBreadcrumbs('Breadcrumbs Test').should('exist'); + getWorkflowCardBreadcrumbsEllipsis('Breadcrumbs Test').should('exist'); + getWorkflowCardBreadcrumbsEllipsis('Breadcrumbs Test').realHover({ position: 'topLeft' }); + cy.get('[role=tooltip]').should('exist'); + cy.get('[role=tooltip]').should( + 'contain.text', + 'est workflow breadcrumbs / Parent Folder / Child Folder / Child Folder 2', + ); + }); }); }); diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index 5e7a298890..a57d9d22ea 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -19,12 +19,12 @@ export class WorkflowsPage extends BasePage { cy.getByTestId('add-resource-workflow').should('be.visible'); return cy.getByTestId('add-resource-workflow'); }, - workflowCards: () => cy.getByTestId('resources-list-item'), + workflowCards: () => cy.getByTestId('resources-list-item-workflow'), workflowCard: (workflowName: string) => this.getters .workflowCards() .contains(workflowName) - .parents('[data-test-id="resources-list-item"]'), + .parents('[data-test-id="resources-list-item-workflow"]'), workflowTags: (workflowName: string) => this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-tags'), workflowCardContent: (workflowName: string) => diff --git a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue index 75b5fa2607..0c9c4e0773 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nActionBox/ActionBox.vue @@ -37,7 +37,7 @@ withDefaults(defineProps(), { {{ heading }} -
+
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 1c82fe2cdc..38a7bb9c89 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue @@ -19,6 +19,7 @@ type Props = { loadingSkeletonRows?: number; separator?: string; highlightLastItem?: boolean; + hiddenItemsTrigger?: 'hover' | 'click'; // Setting this to true will show the ellipsis even if there are no hidden items pathTruncated?: boolean; }; @@ -40,6 +41,7 @@ const props = withDefaults(defineProps(), { separator: '/', highlightLastItem: true, isPathTruncated: false, + hiddenItemsTrigger: 'click', }); const loadedHiddenItems = ref([]); @@ -170,7 +172,8 @@ const handleTooltipClose = () => { v-else :popper-class="$style.tooltip" :disabled="dropdownDisabled" - trigger="click" + :trigger="hiddenItemsTrigger" + placement="bottom" @before-show="handleTooltipShow" @hide="handleTooltipClose" > @@ -313,6 +316,7 @@ const handleTooltipClose = () => { .tooltip { padding: var(--spacing-xs) var(--spacing-2xs); + text-align: center; & > div { color: var(--color-text-lighter); span { @@ -352,6 +356,7 @@ const handleTooltipClose = () => { color: var(--color-text-base); font-size: var(--font-size-2xs); font-weight: var(--font-weight-bold); + line-height: var(--font-line-heigh-xsmall); } .item a:hover * { diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.scss index 223f548b88..6aa16e2152 100644 --- a/packages/frontend/@n8n/design-system/src/css/_tokens.scss +++ b/packages/frontend/@n8n/design-system/src/css/_tokens.scss @@ -616,6 +616,7 @@ --font-size-xl: 1.25rem; --font-size-2xl: 1.75rem; + --font-line-heigh-xsmall: 1; --font-line-height-compact: 1.25; --font-line-height-regular: 1.3; --font-line-height-loose: 1.35; diff --git a/packages/frontend/@n8n/design-system/src/types/user.ts b/packages/frontend/@n8n/design-system/src/types/user.ts index b5b7f512cf..4af8b52dbc 100644 --- a/packages/frontend/@n8n/design-system/src/types/user.ts +++ b/packages/frontend/@n8n/design-system/src/types/user.ts @@ -16,6 +16,7 @@ export interface UserAction { value: string; disabled: boolean; type?: 'external-link'; + tooltip?: string; guard?: (user: IUser) => boolean; } diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 98e87b5643..c44867141d 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -326,6 +326,7 @@ export interface IWorkflowDb { versionId: string; usedCredentials?: IUsedCredential[]; meta?: WorkflowMetadata; + parentFolder?: { id: string; name: string }; } // For workflow list we don't need the full workflow data @@ -339,7 +340,6 @@ export type WorkflowListItem = Omit< 'nodes' | 'connections' | 'settings' | 'pinData' | 'usedCredentials' | 'meta' > & { resource: 'workflow'; - parentFolder?: { id: string; name: string }; }; export type FolderShortInfo = { @@ -363,6 +363,10 @@ export interface FolderListItem extends BaseFolderItem { resource: 'folder'; } +export interface ChangeLocationSearchResult extends BaseFolderItem { + resource: 'folder' | 'project'; +} + export type FolderPathItem = PathItem & { parentFolder?: string }; export type WorkflowListResource = WorkflowListItem | FolderListItem; diff --git a/packages/frontend/editor-ui/src/api/workflows.ts b/packages/frontend/editor-ui/src/api/workflows.ts index 9d2bd5c0c6..c3d23a22ef 100644 --- a/packages/frontend/editor-ui/src/api/workflows.ts +++ b/packages/frontend/editor-ui/src/api/workflows.ts @@ -1,6 +1,6 @@ import type { + ChangeLocationSearchResult, FolderCreateResponse, - FolderListItem, FolderTreeResponseItem, IExecutionResponse, IExecutionsCurrentSummaryExtended, @@ -146,8 +146,8 @@ export async function getProjectFolders( excludeFolderIdAndDescendants?: string; name?: string; }, -): Promise { - const res = await getFullApiResponse( +): Promise { + const res = await getFullApiResponse( context, 'GET', `/projects/${projectId}/folders`, diff --git a/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue b/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue index 6db5e70426..fddfe8e3fd 100644 --- a/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue +++ b/packages/frontend/editor-ui/src/components/CommunityPlusEnrollmentModal.vue @@ -15,6 +15,7 @@ const props = defineProps<{ modalName: string; data?: { closeCallback?: () => void; + customHeading?: string; }; }>(); @@ -89,7 +90,7 @@ const confirm = async () => { {{ i18n.baseText('communityPlusModal.badge') }}

{{ - i18n.baseText('communityPlusModal.title') + data?.customHeading ?? i18n.baseText('communityPlusModal.title') }} {{ i18n.baseText('communityPlusModal.description') }}
    @@ -114,6 +115,13 @@ const confirm = async () => { {{ i18n.baseText('communityPlusModal.features.third.description') }} +
  • + 📁 + + {{ i18n.baseText('communityPlusModal.features.fourth.title') }} + {{ i18n.baseText('communityPlusModal.features.fourth.description') }} + +
(null); -const projectFolders = ref([]); - -const currentFolder = computed(() => { - return projectFolders.value.find((folder) => folder.id === props.activeId); -}); +const selectedFolder = ref<{ id: string; name: string; type: 'folder' | 'project' } | null>(null); const folderToDelete = computed(() => { if (!props.activeId) return null; @@ -72,6 +67,14 @@ const enabled = computed(() => { return false; }); +const currentProjectName = computed(() => { + const currentProject = projectsStore.currentProject; + if (currentProject?.type === ProjectTypes.Personal) { + return i18n.baseText('projects.menu.personal'); + } + return currentProject?.name; +}); + const folderContentWarningMessage = computed(() => { const folderCount = props.data.content.subFolderCount ?? 0; const workflowCount = props.data.content.workflowCount ?? 0; @@ -102,11 +105,10 @@ async function onSubmit() { try { loading.value = true; - await foldersStore.deleteFolder( - route.params.projectId as string, - props.activeId, - selectedFolder.value?.id ?? undefined, - ); + const newParentId = + selectedFolder.value?.type === 'project' ? '0' : (selectedFolder.value?.id ?? undefined); + + await foldersStore.deleteFolder(route.params.projectId as string, props.activeId, newParentId); let message = ''; if (selectedFolder.value) { @@ -132,7 +134,7 @@ async function onSubmit() { } } -const onFolderSelected = (payload: { id: string; name: string }) => { +const onFolderSelected = (payload: { id: string; name: string; type: 'folder' | 'project' }) => { selectedFolder.value = payload; }; @@ -142,7 +144,7 @@ const onFolderSelected = (payload: { id: string; name: string }) => { :name="modalName" :title="title" :center="true" - width="520" + width="600" :event-bus="modalBus" @enter="onSubmit" > @@ -163,7 +165,14 @@ const onFolderSelected = (payload: { id: string; name: string }) => { label="transfer" @update:model-value="operation = 'transfer'" > - {{ i18n.baseText('folders.transfer.action') }} + {{ + i18n.baseText('folders.transfer.action', { + interpolate: { projectName: currentProjectName }, + }) + }} + {{ + i18n.baseText('folders.transfer.action.noProject') + }}
{{ @@ -173,8 +182,8 @@ const onFolderSelected = (payload: { id: string; name: string }) => { v-if="projectsStore.currentProject" :current-folder-id="props.activeId" :current-project-id="projectsStore.currentProject?.id" - :parent-folder-id="currentFolder?.parentFolder?.id" - @folder:selected="onFolderSelected" + :parent-folder-id="folderToDelete?.parentFolder" + @location:selected="onFolderSelected" />
{ { align-items: center; } +.action-toggle { + span[role='button'] { + color: var(--color-text-base); + } +} + .home-project { display: flex; align-items: center; diff --git a/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue b/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue index d8d9ca7553..ebdcbf1b78 100644 --- a/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue +++ b/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue @@ -1,9 +1,11 @@ @@ -71,22 +117,35 @@ const onFolderSelected = (folderId: string) => { v-model="selectedFolderId" :filterable="true" :remote="true" - :remote-method="fetchAvailableFolders" + :remote-method="fetchAvailableLocations" :loading="loading" :placeholder="i18n.baseText('folders.move.modal.select.placeholder')" + :no-data-text="i18n.baseText('folders.move.modal.no.data.label')" option-label="name" option-value="id" @update:model-value="onFolderSelected" > +
- - {{ folder.name }} + + + {{ location.name }}
diff --git a/packages/frontend/editor-ui/src/components/Folders/MoveToFolderModal.vue b/packages/frontend/editor-ui/src/components/Folders/MoveToFolderModal.vue index c5d32d0ea6..1bbdd8181a 100644 --- a/packages/frontend/editor-ui/src/components/Folders/MoveToFolderModal.vue +++ b/packages/frontend/editor-ui/src/components/Folders/MoveToFolderModal.vue @@ -48,7 +48,7 @@ const currentFolder = computed(() => { }; }); -const onFolderSelected = (payload: { id: string; name: string }) => { +const onFolderSelected = (payload: { id: string; name: string; type: string }) => { selectedFolder.value = payload; }; @@ -81,7 +81,7 @@ const onSubmit = () => { :current-project-id="projectsStore.currentProject?.id" :parent-folder-id="props.data.resource.parentFolderId" :exclude-only-parent="props.data.resourceType === 'workflow'" - @folder:selected="onFolderSelected" + @location:selected="onFolderSelected" />

{ return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id)); }); +const currentFolder = computed(() => { + if (props.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) { + return undefined; + } + + const workflow = workflowsStore.getWorkflowById(props.id); + if (!workflow) { + return undefined; + } + + return workflow.parentFolder; +}); + +const currentProjectName = computed(() => { + if (projectsStore.currentProject?.type === ProjectTypes.Personal) { + return locale.baseText('projects.menu.personal'); + } + return projectsStore.currentProject?.name; +}); + watch( () => props.id, () => { @@ -533,16 +554,22 @@ function showCreateWorkflowSuccessToast(id?: string) { let toastTitle = locale.baseText('workflows.create.personal.toast.title'); let toastText = locale.baseText('workflows.create.personal.toast.text'); - if ( - projectsStore.currentProject && - projectsStore.currentProject.id !== projectsStore.personalProject?.id - ) { - toastTitle = locale.baseText('workflows.create.project.toast.title', { - interpolate: { projectName: projectsStore.currentProject.name ?? '' }, - }); + if (projectsStore.currentProject) { + if (currentFolder.value) { + toastTitle = locale.baseText('workflows.create.folder.toast.title', { + interpolate: { + projectName: currentProjectName.value ?? '', + folderName: currentFolder.value.name ?? '', + }, + }); + } else if (projectsStore.currentProject.id !== projectsStore.personalProject?.id) { + toastTitle = locale.baseText('workflows.create.project.toast.title', { + interpolate: { projectName: currentProjectName.value ?? '' }, + }); + } toastText = locale.baseText('workflows.create.project.toast.text', { - interpolate: { projectName: projectsStore.currentProject.name ?? '' }, + interpolate: { projectName: currentProjectName.value ?? '' }, }); } diff --git a/packages/frontend/editor-ui/src/components/PersonalizationModal.vue b/packages/frontend/editor-ui/src/components/PersonalizationModal.vue index ea45b37324..84051691fb 100644 --- a/packages/frontend/editor-ui/src/components/PersonalizationModal.vue +++ b/packages/frontend/editor-ui/src/components/PersonalizationModal.vue @@ -567,6 +567,7 @@ const closeDialog = () => { name: COMMUNITY_PLUS_ENROLLMENT_MODAL, data: { closeCallback, + customHeading: undefined, }, }); } else { diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index b0d053a3a5..b7d26432b2 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -56,6 +56,7 @@ const showSettings = computed( ); const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject); + const showFolders = computed(() => { return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS; }); @@ -189,7 +190,7 @@ const onSelect = (action: string) => { } .actions { - padding: var(--spacing-2xs) 0 var(--spacing-l); + padding: var(--spacing-2xs) 0 var(--spacing-xs); } @include mixins.breakpoint('xs-only') { diff --git a/packages/frontend/editor-ui/src/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/components/WorkflowCard.vue index 8503bb4dbc..b39c0f8a06 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowCard.vue @@ -1,5 +1,5 @@