fix(editor): Addressing internal testing feedback for folders (no-changelog) (#13997)

This commit is contained in:
Milorad FIlipović
2025-03-20 15:48:10 +01:00
committed by GitHub
parent 305ea0fb32
commit 1f56a24bbd
35 changed files with 1277 additions and 145 deletions

View File

@@ -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');
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -37,7 +37,7 @@ withDefaults(defineProps<ActionBoxProps>(), {
<slot name="heading">{{ heading }}</slot>
</N8nHeading>
</div>
<div :class="$style.description" @click="$emit('descriptionClick', $event)">
<div v-if="description" :class="$style.description" @click="$emit('descriptionClick', $event)">
<N8nText color="text-base">
<slot name="description">
<span v-n8n-html="description"></span>

View File

@@ -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<Props>(), {
separator: '/',
highlightLastItem: true,
isPathTruncated: false,
hiddenItemsTrigger: 'click',
});
const loadedHiddenItems = ref<PathItem[]>([]);
@@ -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 * {

View File

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

View File

@@ -16,6 +16,7 @@ export interface UserAction {
value: string;
disabled: boolean;
type?: 'external-link';
tooltip?: string;
guard?: (user: IUser) => boolean;
}

View File

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

View File

@@ -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<FolderListItem[]> {
const res = await getFullApiResponse<FolderListItem[]>(
): Promise<ChangeLocationSearchResult[]> {
const res = await getFullApiResponse<ChangeLocationSearchResult[]>(
context,
'GET',
`/projects/${projectId}/folders`,

View File

@@ -15,6 +15,7 @@ const props = defineProps<{
modalName: string;
data?: {
closeCallback?: () => void;
customHeading?: string;
};
}>();
@@ -89,7 +90,7 @@ const confirm = async () => {
<N8nBadge>{{ i18n.baseText('communityPlusModal.badge') }}</N8nBadge>
</p>
<N8nText tag="h1" align="center" size="xlarge" class="mb-m">{{
i18n.baseText('communityPlusModal.title')
data?.customHeading ?? i18n.baseText('communityPlusModal.title')
}}</N8nText>
<N8nText tag="p">{{ i18n.baseText('communityPlusModal.description') }}</N8nText>
<ul :class="$style.features">
@@ -114,6 +115,13 @@ const confirm = async () => {
{{ i18n.baseText('communityPlusModal.features.third.description') }}
</N8nText>
</li>
<li>
<i> 📁</i>
<N8nText>
<strong>{{ i18n.baseText('communityPlusModal.features.fourth.title') }}</strong>
{{ i18n.baseText('communityPlusModal.features.fourth.description') }}
</N8nText>
</li>
</ul>
<N8nFormInput
id="email"

View File

@@ -6,8 +6,8 @@ import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@/composables/useI18n';
import { useFoldersStore } from '@/stores/folders.store';
import { useRoute } from 'vue-router';
import type { FolderListItem } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store';
import { ProjectTypes } from '@/types/projects.types';
const props = defineProps<{
modalName: string;
@@ -32,12 +32,7 @@ const projectsStore = useProjectsStore();
const loading = ref(false);
const operation = ref('');
const deleteConfirmText = ref('');
const selectedFolder = ref<{ id: string; name: string } | null>(null);
const projectFolders = ref<FolderListItem[]>([]);
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;
};
</script>
@@ -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'"
>
<n8n-text color="text-dark">{{ i18n.baseText('folders.transfer.action') }}</n8n-text>
<n8n-text v-if="currentProjectName">{{
i18n.baseText('folders.transfer.action', {
interpolate: { projectName: currentProjectName },
})
}}</n8n-text>
<n8n-text v-else color="text-dark">{{
i18n.baseText('folders.transfer.action.noProject')
}}</n8n-text>
</el-radio>
<div v-if="operation === 'transfer'" :class="$style.optionInput">
<n8n-text color="text-dark">{{
@@ -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"
/>
</div>
<el-radio

View File

@@ -65,6 +65,7 @@ const onAction = (action: string) => {
<n8n-action-toggle
v-if="breadcrumbs.visibleItems"
:actions="actions"
:class="$style['action-toggle']"
theme="dark"
data-test-id="folder-breadcrumbs-actions"
@action="onAction"
@@ -78,6 +79,12 @@ const onAction = (action: string) => {
align-items: center;
}
.action-toggle {
span[role='button'] {
color: var(--color-text-base);
}
}
.home-project {
display: flex;
align-items: center;

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import type { FolderListItem } from '@/Interface';
import type { ChangeLocationSearchResult } from '@/Interface';
import { useFoldersStore } from '@/stores/folders.store';
import { useProjectsStore } from '@/stores/projects.store';
import { type ProjectIcon as ItemProjectIcon, ProjectTypes } from '@/types/projects.types';
import { N8nSelect } from '@n8n/design-system';
import { ref } from 'vue';
import { computed, ref } from 'vue';
/**
* This component is used to select a folder to move a resource (folder or workflow) to.
@@ -24,21 +26,43 @@ const props = withDefaults(defineProps<Props>(), {
});
const emit = defineEmits<{
'folder:selected': [value: { id: string; name: string }];
'location:selected': [value: { id: string; name: string; type: 'folder' | 'project' }];
}>();
const i18n = useI18n();
const foldersStore = useFoldersStore();
const projectsStore = useProjectsStore();
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
const selectedFolderId = ref<string | null>(null);
const availableFolders = ref<FolderListItem[]>([]);
const availableLocations = ref<ChangeLocationSearchResult[]>([]);
const loading = ref(false);
const fetchAvailableFolders = async (query?: string) => {
const currentProject = computed(() => {
return projectsStore.currentProject;
});
const projectName = computed(() => {
if (currentProject.value?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject.value?.name;
});
const projectIcon = computed<ItemProjectIcon>(() => {
const defaultIcon: ItemProjectIcon = { type: 'icon', value: 'layer-group' };
if (currentProject.value?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (currentProject.value?.type === ProjectTypes.Team) {
return currentProject.value.icon ?? defaultIcon;
}
return defaultIcon;
});
const fetchAvailableLocations = async (query?: string) => {
if (!query) {
availableFolders.value = [];
availableLocations.value = [];
return;
}
loading.value = true;
@@ -48,19 +72,41 @@ const fetchAvailableFolders = async (query?: string) => {
{ name: query ?? undefined },
);
if (!props.parentFolderId) {
availableFolders.value = folders;
availableLocations.value = folders;
} else {
availableFolders.value = folders.filter((folder) => folder.id !== props.parentFolderId);
availableLocations.value = folders.filter((folder) => folder.id !== props.parentFolderId);
}
// Finally add project root if project name contains query (only if folder is not already in root)
if (
projectName.value &&
projectName.value.toLowerCase().includes(query.toLowerCase()) &&
props.parentFolderId !== ''
) {
availableLocations.value.unshift({
id: props.currentProjectId,
name: i18n.baseText('folders.move.project.root.name', {
interpolate: { projectName: projectName.value },
}),
resource: 'project',
createdAt: '',
updatedAt: '',
workflowCount: 0,
subFolderCount: 0,
});
}
loading.value = false;
};
const onFolderSelected = (folderId: string) => {
const selectedFolder = availableFolders.value.find((folder) => folder.id === folderId);
const selectedFolder = availableLocations.value.find((folder) => folder.id === folderId);
if (!selectedFolder) {
return;
}
emit('folder:selected', { id: folderId, name: selectedFolder.name });
emit('location:selected', {
id: folderId,
name: selectedFolder.name,
type: selectedFolder.resource,
});
};
</script>
@@ -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"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
<N8nOption
v-for="folder in availableFolders"
:key="folder.id"
:value="folder.id"
:label="folder.name"
v-for="location in availableLocations"
:key="location.id"
:value="location.id"
:label="location.name"
data-test-id="move-to-folder-option"
>
<div :class="$style['folder-select-item']">
<n8n-icon :class="$style['folder-icon']" icon="folder" />
<span :class="$style['folder-name']"> {{ folder.name }}</span>
<ProjectIcon
v-if="location.resource === 'project' && currentProject"
:class="$style['folder-icon']"
:icon="projectIcon"
:border-less="true"
size="mini"
color="text-dark"
/>
<n8n-icon v-else :class="$style['folder-icon']" icon="folder" />
<span :class="$style['folder-name']"> {{ location.name }}</span>
</div>
</N8nOption>
</N8nSelect>

View File

@@ -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"
/>
<p
v-if="props.data.resourceType === 'folder'"

View File

@@ -55,6 +55,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
import type { BaseTextKey } from '@/plugins/i18n';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { ProjectTypes } from '@/types/projects.types';
const props = defineProps<{
readOnly?: boolean;
@@ -200,6 +201,26 @@ const workflowTagIds = computed(() => {
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 ?? '' },
});
}

View File

@@ -567,6 +567,7 @@ const closeDialog = () => {
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
data: {
closeCallback,
customHeading: undefined,
},
});
} else {

View File

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import {
DUPLICATE_MODAL_KEY,
MODAL_CONFIRM,
@@ -26,6 +26,9 @@ import { ResourceType } from '@/utils/projects.utils';
import type { EventBus } from '@n8n/utils/event-bus';
import type { WorkflowResource } from './layouts/ResourcesListLayout.vue';
import type { IUser } from 'n8n-workflow';
import { type ProjectIcon as CardProjectIcon, ProjectTypes } from '@/types/projects.types';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useFoldersStore } from '@/stores/folders.store';
const WORKFLOW_LIST_ITEM_ACTIONS = {
OPEN: 'open',
@@ -68,15 +71,56 @@ const uiStore = useUIStore();
const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
const showFolders = computed(() => {
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
});
const projectIcon = computed<CardProjectIcon>(() => {
const defaultIcon: CardProjectIcon = { type: 'icon', value: 'layer-group' };
if (props.data.homeProject?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (props.data.homeProject?.type === ProjectTypes.Team) {
return props.data.homeProject.icon ?? defaultIcon;
}
return defaultIcon;
});
const projectName = computed(() => {
if (props.data.homeProject?.type === ProjectTypes.Personal) {
return locale.baseText('projects.menu.personal');
}
return props.data.homeProject?.name;
});
const cardBreadcrumbs = computed<PathItem[]>(() => {
if (props.data.parentFolder) {
return [
{
id: props.data.parentFolder.id,
name: props.data.parentFolder.name,
label: props.data.parentFolder.name,
href: router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
params: {
projectId: props.data.homeProject?.id,
folderId: props.data.parentFolder.id,
},
}).href,
},
];
}
return [];
});
const actions = computed(() => {
const items = [
{
@@ -236,6 +280,17 @@ async function deleteWorkflow() {
emit('workflow:deleted');
}
const fetchHiddenBreadCrumbsItems = async () => {
if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
} else {
hiddenBreadcrumbsItemsAsync.value = foldersStore.getHiddenBreadcrumbsItems(
{ id: props.data.homeProject.id, name: projectName.value },
props.data.parentFolder.id,
);
}
};
function moveResource() {
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
@@ -251,6 +306,12 @@ function moveResource() {
const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
emit('workflow:active-toggle', value);
};
const onBreadcrumbItemClick = async (item: PathItem) => {
if (item.href) {
await router.push(item.href);
}
};
</script>
<template>
@@ -289,7 +350,33 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
</div>
<template #append>
<div :class="$style.cardActions" @click.stop>
<div v-if="isOverviewPage" :class="$style.breadcrumbs">
<n8n-breadcrumbs
:items="cardBreadcrumbs"
:hidden-items="hiddenBreadcrumbsItemsAsync"
:path-truncated="true"
:show-border="true"
:highlight-last-item="false"
hidden-items-trigger="hover"
theme="small"
data-test-id="workflow-card-breadcrumbs"
@tooltip-opened="fetchHiddenBreadCrumbsItems"
@item-selected="onBreadcrumbItemClick"
>
<template v-if="data.homeProject" #prepend>
<div :class="$style['home-project']">
<n8n-link :to="`/projects/${data.homeProject.id}`">
<ProjectIcon :icon="projectIcon" :border-less="true" size="mini" />
<n8n-text size="small" :compact="true" :bold="true" color="text-base">{{
projectName
}}</n8n-text>
</n8n-link>
</div>
</template>
</n8n-breadcrumbs>
</div>
<ProjectCardBadge
v-else
:class="$style.cardBadge"
:resource="data"
:resource-type="ResourceType.Workflow"

View File

@@ -113,6 +113,7 @@ onBeforeMount(async () => {
<n8n-button
icon="filter"
type="tertiary"
size="small"
:active="hasFilters"
:class="{
[$style['filter-button']]: true,
@@ -165,7 +166,8 @@ onBeforeMount(async () => {
<style lang="scss" module>
.filter-button {
height: 40px;
height: 30px;
width: 30px;
align-items: center;
&.no-label {

View File

@@ -543,6 +543,7 @@ const loadPaginationFromQueryString = async () => {
:model-value="filtersModel.search"
:class="$style.search"
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
size="small"
clearable
data-test-id="resources-list-search"
@update:model-value="onSearch"
@@ -552,7 +553,7 @@ const loadPaginationFromQueryString = async () => {
</template>
</n8n-input>
<div :class="$style['sort-and-filter']">
<n8n-select v-model="sortBy" data-test-id="resources-list-sort">
<n8n-select v-model="sortBy" size="small" data-test-id="resources-list-sort">
<n8n-option
v-for="sortOption in sortOptions"
:key="sortOption"
@@ -660,7 +661,12 @@ const loadPaginationFromQueryString = async () => {
</n8n-datatable>
</div>
<n8n-text v-else color="text-base" size="medium" data-test-id="resources-list-empty">
<n8n-text
v-else-if="hasAppliedFilters() || filtersModel.search !== ''"
color="text-base"
size="medium"
data-test-id="resources-list-empty"
>
{{ i18n.baseText(`${resourceKey}.noResults` as BaseTextKey) }}
</n8n-text>
@@ -684,14 +690,14 @@ const loadPaginationFromQueryString = async () => {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr max-content max-content max-content;
gap: var(--spacing-2xs);
gap: var(--spacing-4xs);
align-items: center;
justify-content: end;
width: 100%;
.sort-and-filter {
display: flex;
gap: var(--spacing-2xs);
gap: var(--spacing-4xs);
align-items: center;
}
@@ -707,7 +713,7 @@ const loadPaginationFromQueryString = async () => {
justify-self: end;
input {
height: 42px;
height: 30px;
}
}

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { useFolders } from './useFolders';
import { FOLDER_NAME_MAX_LENGTH } from '@/constants';
describe('useFolders', () => {
const { validateFolderName } = useFolders();
describe('validateFolderName', () => {
describe('Valid folder names', () => {
const validNames = [
'normal-folder',
'folder_with_underscore',
'folder with spaces',
'folder123',
'UPPERCASE',
'MixedCase',
'123numbers',
'a', // Single character
'folder.with.dots', // Dots in the middle are fine
'folder-with-dashes',
'folder(with)parentheses',
'folder+with+plus',
'folder&with&ampersand',
"folder'with'quotes",
'folder,with,commas',
'folder;with;semicolons',
'folder=with=equals',
'folder~with~tilde',
];
validNames.forEach((name) => {
it(`should validate "${name}" as a valid folder name`, () => {
expect(validateFolderName(name)).toBe(true);
});
});
});
describe('Folder names with illegal starting dots', () => {
const namesWithDots = ['.hidden', '..parent', '...multiple', '.', '..', '...'];
namesWithDots.forEach((name) => {
it(`should reject "${name}" as it starts with dot(s)`, () => {
const result = validateFolderName(name);
if (name === '.' || name === '..' || name === '...') {
expect(result).toBe('Folder name cannot contain only dots');
} else {
expect(result).toBe('Folder name cannot start with a dot');
}
});
});
});
describe('Folder names with illegal characters', () => {
const illegalCharacterCases = [
{ name: 'folder[bracketed]', char: '[' },
{ name: 'folder]bracketed', char: ']' },
{ name: 'folder^caret', char: '^' },
{ name: 'folder\\backslash', char: '\\' },
{ name: 'folder/slash', char: '/' },
{ name: 'folder:colon', char: ':' },
{ name: 'folder*asterisk', char: '*' },
{ name: 'folder?question', char: '?' },
{ name: 'folder"quotes', char: '"' },
{ name: 'folder<angle', char: '<' },
{ name: 'folder>angle', char: '>' },
{ name: 'folder|pipe', char: '|' },
{ name: '???', char: '?' },
];
illegalCharacterCases.forEach(({ name, char }) => {
it(`should reject "${name}" as it contains illegal character "${char}"`, () => {
const result = validateFolderName(name);
expect(result).toBe(
'Folder name cannot contain the following characters: [ ] ^ \\ / : * ? " < > |',
);
});
});
});
it('should reject folder names longer than the maximum length', () => {
const longName = 'a'.repeat(FOLDER_NAME_MAX_LENGTH + 1);
const result = validateFolderName(longName);
expect(result).toBe(`Folder name cannot be longer than ${FOLDER_NAME_MAX_LENGTH} characters`);
});
describe('Edge cases', () => {
it('should handle empty string input', () => {
// Decide on your desired behavior for empty strings
// This is implementation-dependent - modify as needed
const result = validateFolderName('');
expect(typeof result).toBe('string'); // Expecting an error message
});
it('should handle folder names with Unicode characters', () => {
const unicodeNames = ['folder-with-émojis-😊', '中文文件夹', 'мој фолдер', 'مجلد-عربي'];
unicodeNames.forEach((name) => {
expect(validateFolderName(name)).toBe(true);
});
});
it('should handle folder names with multiple spaces', () => {
expect(validateFolderName('folder with spaces')).toBe(true);
});
});
describe('Combined invalid cases', () => {
it('should prioritize illegal characters when name has both issues', () => {
const result = validateFolderName('.folder*with/illegal:chars');
// Expect to get the illegal characters first since that's the order of checks
expect(result).toBe(
'Folder name cannot contain the following characters: [ ] ^ \\ / : * ? " < > |',
);
});
it('should check for dots-only before starting dots', () => {
const result = validateFolderName('...');
expect(result).toBe('Folder name cannot contain only dots');
});
});
});
});

View File

@@ -0,0 +1,46 @@
import {
FOLDER_NAME_ILLEGAL_CHARACTERS_REGEX,
FOLDER_NAME_MAX_LENGTH,
FOLDER_NAME_ONLY_DOTS_REGEX,
ILLEGAL_FOLDER_CHARACTERS,
} from '@/constants';
import { useI18n } from './useI18n';
export function useFolders() {
const i18n = useI18n();
function validateFolderName(folderName: string): true | string {
if (FOLDER_NAME_ILLEGAL_CHARACTERS_REGEX.test(folderName)) {
return i18n.baseText('folders.invalidName.invalidCharacters.message', {
interpolate: {
illegalChars: ILLEGAL_FOLDER_CHARACTERS.join(' '),
},
});
}
if (FOLDER_NAME_ONLY_DOTS_REGEX.test(folderName)) {
return i18n.baseText('folders.invalidName.only.dots.message');
}
if (folderName.startsWith('.')) {
return i18n.baseText('folders.invalidName.starts.with.dot..message');
}
if (folderName.trim() === '') {
return i18n.baseText('folders.invalidName.empty.message');
}
if (folderName.length > FOLDER_NAME_MAX_LENGTH) {
return i18n.baseText('folders.invalidName.tooLong.message', {
interpolate: {
maxLength: FOLDER_NAME_MAX_LENGTH,
},
});
}
return true;
}
return {
validateFolderName,
};
}

View File

@@ -34,6 +34,7 @@ export const useGlobalEntityCreation = () => {
const cloudPlanStore = useCloudPlanStore();
const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore();
const router = useRouter();
const i18n = useI18n();
const toast = useToast();

View File

@@ -430,11 +430,28 @@ export const MODAL_CANCEL = 'cancel';
export const MODAL_CONFIRM = 'confirm';
export const MODAL_CLOSE = 'close';
/**
* Invalid characters: \/:*?"<>|
* Invalid name: empty or only dots
*/
export const VALID_FOLDER_NAME_REGEX = /^(?!\.+$)(?!\s+$)[^\\/:*?"<>|]{1,100}$/;
export const ILLEGAL_FOLDER_CHARACTERS = [
'[',
']',
'^',
'\\',
'/',
':',
'*',
'?',
'"',
'<',
'>',
'|',
];
export const FOLDER_NAME_ILLEGAL_CHARACTERS_REGEX = new RegExp(
`[${ILLEGAL_FOLDER_CHARACTERS.map((char) => {
return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}).join('')}]`,
);
export const FOLDER_NAME_ONLY_DOTS_REGEX = /^\.+$/;
export const FOLDER_NAME_MAX_LENGTH = 100;
export const VALID_EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i;

View File

@@ -36,6 +36,7 @@
"generic.close": "Close",
"generic.confirm": "Confirm",
"generic.create": "Create",
"generic.create.workflow": "Create Workflow",
"generic.deleteWorkflowError": "Problem deleting workflow",
"generic.filtersApplied": "Filters are currently applied.",
"generic.field": "field",
@@ -92,6 +93,7 @@
"generic.viewDocs": "View docs",
"generic.workflows": "Workflows",
"generic.rename": "Rename",
"generic.missing.permissions": "Missing permissions to perform this action",
"about.aboutN8n": "About n8n",
"about.close": "Close",
"about.license": "License",
@@ -900,15 +902,21 @@
"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.create": "Create folder inside",
"folders.actions.create.workflow": "Create workflow inside",
"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.overview.community.message": "Folders available in your personal space",
"folders.add.overview.withProjects.message": "Folders available in projects or your personal space",
"folders.add.success.title": "Folder created",
"folders.add.success.message": "Created new folder \"{folderName}\"<br><a href=\"{link}\">Open folder</a>",
"folders.invalidName.message": "Please provide a valid folder name",
"folders.invalidName.empty.message": "Folder name cannot be empty",
"folders.invalidName.tooLong.message": "Folder name cannot be longer than {maxLength} characters",
"folders.invalidName.invalidCharacters.message": "Folder name cannot contain the following characters: {illegalChars}",
"folders.invalidName.starts.with.dot..message": "Folder name cannot start with a dot",
"folders.invalidName.only.dots.message": "Folder name cannot contain only dots",
"folders.delete.confirm.title": "Delete \"{folderName}\"",
"folders.delete.typeToConfirm": "delete {folderName}",
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
@@ -921,7 +929,8 @@
"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.action": "Transfer workflows and subfolders to another folder inside \"{projectName}\"",
"folders.transfer.action.noProject": "Transfer workflows and subfolders to another folder",
"folders.transfer.selectFolder": "Folder to transfer to",
"folders.transfer.select.placeholder": "Select folder",
"folders.rename.message": "Rename \"{folderName}\"",
@@ -932,14 +941,19 @@
"folders.move.modal.description": "Note: Moving this folder will also move all workflows and subfolders within it.",
"folders.move.modal.select.placeholder": "Search for a folder",
"folders.move.modal.confirm": "Move to folder",
"folders.move.modal.no.data.label": "No folders found",
"folders.move.success.title": "Successfully moved folder",
"folders.move.success.message": "<b>{folderName}</b> has been moved to <b>{newFolderName}</b>, along with all its workflows and subfolders.<br/><br/><a href=\"{link}\">View {newFolderName}</a>",
"folders.move.error.title": "Problem moving folder",
"folders.move.workflow.error.title": "Problem moving workflow",
"folders.move.workflow.success.title": "Successfully moved workflow",
"folders.move.workflow.success.message": "<b>{workflowName}</b> has been moved to <b>{newFolderName}</b>.<br/><br/><a href=\"{link}\">View {newFolderName}</a>",
"folders.move.project.root.name": "{projectName} (Project root)",
"folders.open.error.title": "Problem opening folder",
"folders.create.error.title": "Problem creating folder",
"folders.empty.actionbox.title": "Nothing in folder \"{folderName}\" yet",
"folders.registeredCommunity.cta.heading": "Get access to folders with registered community",
"folders.breadcrumbs.noTruncated.message": "No hidden items in path",
"generic.oauth1Api": "OAuth1 API",
"generic.oauth2Api": "OAuth2 API",
"genericHelpers.loading": "Loading",
@@ -2465,6 +2479,7 @@
"workflows.create.personal.toast.title": "Workflow successfully created",
"workflows.create.personal.toast.text": "This workflow has been created inside your personal space.",
"workflows.create.project.toast.title": "Workflow successfully created in {projectName}",
"workflows.create.folder.toast.title": "Workflow successfully created in \"{projectName}\", within \"{folderName}\"",
"workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.",
"workflowSelectorParameterInput.createNewSubworkflow.name": "My Sub-Workflow",
"importCurlModal.title": "Import cURL command",
@@ -2885,6 +2900,8 @@
"communityPlusModal.features.second.description": "Easily fix any workflow execution thats errored, then re-run it",
"communityPlusModal.features.third.title": "Execution search and tagging",
"communityPlusModal.features.third.description": "Search and organize past workflow executions for easier review",
"communityPlusModal.features.fourth.title": "Folders",
"communityPlusModal.features.fourth.description": "Organize your workflows in a nested folder structrue",
"communityPlusModal.input.email.label": "Enter email to receive your license key",
"communityPlusModal.button.skip": "Skip",
"communityPlusModal.button.confirm": "Send me a free license key",

View File

@@ -1,17 +1,19 @@
import { defineStore } from 'pinia';
import { STORES } from '@/constants';
import type {
ChangeLocationSearchResult,
FolderCreateResponse,
FolderListItem,
FolderShortInfo,
FolderTreeResponseItem,
} from '@/Interface';
import * as workflowsApi from '@/api/workflows';
import { useRootStore } from './root.store';
import { ref } from 'vue';
import { useI18n } from '@/composables/useI18n';
export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
const rootStore = useRootStore();
const i18n = useI18n();
const totalWorkflowCount = ref<number>(0);
@@ -119,7 +121,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
filter?: {
name?: string;
},
): Promise<FolderListItem[]> {
): Promise<ChangeLocationSearchResult[]> {
const folders = await workflowsApi.getProjectFolders(
rootStore.restApiContext,
projectId,
@@ -146,6 +148,14 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
parentFolderId?: string,
): Promise<void> {
await workflowsApi.moveFolder(rootStore.restApiContext, projectId, folderId, parentFolderId);
// Update the cache after moving the folder
delete breadcrumbsCache.value[folderId];
if (parentFolderId) {
const folder = breadcrumbsCache.value[folderId];
if (folder) {
folder.parentFolder = parentFolderId;
}
}
}
async function fetchFolderContent(
@@ -155,6 +165,78 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
return await workflowsApi.getFolderContent(rootStore.restApiContext, projectId, folderId);
}
/**
* Fetches the breadcrumbs items for a given folder, excluding the specified folderId.
* @param projectId project in which the folder is located
* @param folderId folder to get the breadcrumbs for
* @returns
*/
async function getHiddenBreadcrumbsItems(
project: { id: string; name: string },
folderId: string,
) {
const path = await getFolderPath(project.id, folderId);
if (path.length === 0) {
// Even when path is empty, include the project item
return [
{
id: project.id,
label: project.name,
},
{
id: '-1',
label: i18n.baseText('folders.breadcrumbs.noTruncated.message'),
},
];
}
// Process a folder and all its nested children recursively
const processFolderWithChildren = (
folder: FolderTreeResponseItem,
): Array<{ id: string; label: string }> => {
const result = [
{
id: folder.id,
label: folder.name,
},
];
// Process all children and their descendants
if (folder.children?.length) {
const childItems = folder.children.flatMap((child) => {
// Add this child
const childResult = [
{
id: child.id,
label: child.name,
},
];
// Add all descendants of this child
if (child.children?.length) {
childResult.push(...processFolderWithChildren(child).slice(1));
}
return childResult;
});
result.push(...childItems);
}
return result;
};
// Start with the project item, then add all processed folders
return [
{
id: project.id,
label: project.name,
},
...path.flatMap(processFolderWithChildren),
];
}
return {
fetchTotalWorkflowsAndFoldersCount,
breadcrumbsCache,
@@ -170,5 +252,6 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
fetchFoldersAvailableForMove,
moveFolder,
fetchFolderContent,
getHiddenBreadcrumbsItems,
};
});

View File

@@ -124,7 +124,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
SETUP_CREDENTIALS_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
NEW_ASSISTANT_SESSION_MODAL,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {
@@ -177,6 +176,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
workflowListEventBus: undefined,
},
},
[COMMUNITY_PLUS_ENROLLMENT_MODAL]: {
open: false,
data: {
customHeading: undefined,
},
},
});
const modalStack = ref<string[]>([]);

View File

@@ -63,7 +63,9 @@ describe('SettingsUsageAndPlan', () => {
expect(getByRole('button', { name: 'Unlock' })).toBeVisible();
await userEvent.click(getByRole('button', { name: 'Unlock' }));
expect(uiStore.openModal).toHaveBeenCalledWith(COMMUNITY_PLUS_ENROLLMENT_MODAL);
expect(uiStore.openModalWithData).toHaveBeenCalledWith(
expect.objectContaining({ name: COMMUNITY_PLUS_ENROLLMENT_MODAL }),
);
});
it('should show community registered badge', async () => {

View File

@@ -148,7 +148,12 @@ const onDialogOpened = () => {
};
const openCommunityRegisterModal = () => {
uiStore.openModal(COMMUNITY_PLUS_ENROLLMENT_MODAL);
uiStore.openModalWithData({
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
data: {
customHeading: undefined,
},
});
};
</script>

View File

@@ -275,7 +275,7 @@ onMounted(() => {
<N8nTooltip placement="top" :disabled="canCreateVariables">
<div>
<N8nButton
size="large"
size="medium"
block
:disabled="!canCreateVariables"
data-test-id="resources-list-add"

View File

@@ -40,6 +40,10 @@ const router = createRouter({
],
});
vi.mock('@/api/usage', () => ({
getLicense: vi.fn(),
}));
let pinia: ReturnType<typeof createTestingPinia>;
let foldersStore: ReturnType<typeof mockedStore<typeof useFoldersStore>>;
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;

View File

@@ -16,7 +16,7 @@ import {
VIEWS,
DEFAULT_WORKFLOW_PAGE_SIZE,
MODAL_CONFIRM,
VALID_FOLDER_NAME_REGEX,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
} from '@/constants';
import type {
IUser,
@@ -59,6 +59,8 @@ import { debounce } from 'lodash-es';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useFoldersStore } from '@/stores/folders.store';
import { useFolders } from '@/composables/useFolders';
import { useUsageStore } from '@/stores/usage.store';
interface Filters extends BaseFilters {
status: string | boolean;
@@ -84,6 +86,7 @@ const route = useRoute();
const router = useRouter();
const message = useMessage();
const toast = useToast();
const folderHelpers = useFolders();
const sourceControlStore = useSourceControlStore();
const usersStore = useUsersStore();
@@ -95,6 +98,7 @@ const telemetry = useTelemetry();
const uiStore = useUIStore();
const tagsStore = useTagsStore();
const foldersStore = useFoldersStore();
const usageStore = useUsageStore();
const documentTitle = useDocumentTitle();
const { callDebounced } = useDebounce();
@@ -180,8 +184,17 @@ const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
const isShareable = computed(
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
);
const foldersEnabled = computed(() => {
return settingsStore.isFoldersFeatureEnabled;
});
const teamProjectsEnabled = computed(() => {
return projectsStore.isTeamProjectFeatureEnabled;
});
const showFolders = computed(() => {
return settingsStore.isFoldersFeatureEnabled && !isOverviewPage.value;
return foldersEnabled.value && !isOverviewPage.value;
});
const currentFolder = computed(() => {
@@ -298,6 +311,19 @@ const emptyListDescription = computed(() => {
}
});
const hasFilters = computed(() => {
return !!(
filters.value.search ||
filters.value.status !== StatusFilter.ALL ||
filters.value.tags.length
);
});
const isCommunity = computed(() => usageStore.planName.toLowerCase() === 'community');
const canUserRegisterCommunityPlus = computed(
() => getResourcePermissions(usersStore.currentUser?.globalScopes).community.register,
);
/**
* WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS
*/
@@ -383,6 +409,7 @@ const initialize = async () => {
usersStore.fetchUsers(),
fetchWorkflows(),
workflowsStore.fetchActiveWorkflows(),
usageStore.getLicenseInfo(),
]);
breadcrumbsLoading.value = false;
workflowsAndFolders.value = resourcesPage;
@@ -837,11 +864,14 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
if (!clickedFolder) return;
switch (payload.action) {
case FOLDER_LIST_ITEM_ACTIONS.CREATE:
await createFolder({
id: clickedFolder.id,
name: clickedFolder.name,
type: 'folder',
});
await createFolder(
{
id: clickedFolder.id,
name: clickedFolder.name,
type: 'folder',
},
{ openAfterCreate: true },
);
break;
case FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW:
currentFolderId.value = clickedFolder.id;
@@ -876,14 +906,16 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
// Reusable action handlers
// Both action handlers ultimately call these methods once folder to apply action to is determined
const createFolder = async (parent: { id: string; name: string; type: 'project' | 'folder' }) => {
const createFolder = async (
parent: { id: string; name: string; type: 'project' | 'folder' },
options: { openAfterCreate: boolean } = { openAfterCreate: false },
) => {
const promptResponsePromise = message.prompt(
i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }),
{
confirmButtonText: i18n.baseText('generic.create'),
cancelButtonText: i18n.baseText('generic.cancel'),
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
inputPattern: VALID_FOLDER_NAME_REGEX,
inputValidator: folderHelpers.validateFolderName,
customClass: 'add-folder-modal',
},
);
@@ -917,31 +949,39 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
},
type: 'success',
});
// If we are on an empty list, just add the new folder to the list
if (!workflowsAndFolders.value.length) {
workflowsAndFolders.value = [
{
id: newFolder.id,
name: newFolder.name,
resource: 'folder',
createdAt: newFolder.createdAt,
updatedAt: newFolder.updatedAt,
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();
}
telemetry.track('User created folder', {
folder_id: newFolder.id,
});
if (options.openAfterCreate) {
// Navigate to parent folder id option specified by the caller
await router.push({
name: VIEWS.PROJECTS_FOLDERS,
params: { projectId: route.params.projectId, folderId: parent.id },
});
} else {
// If we are on an empty list, just add the new folder to the list
if (!workflowsAndFolders.value.length) {
workflowsAndFolders.value = [
{
id: newFolder.id,
name: newFolder.name,
resource: 'folder',
createdAt: newFolder.createdAt,
updatedAt: newFolder.updatedAt,
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();
}
}
} catch (error) {
toast.showError(error, i18n.baseText('folders.create.error.title'));
}
@@ -956,10 +996,9 @@ const renameFolder = async (folderId: string) => {
{
confirmButtonText: i18n.baseText('generic.rename'),
cancelButtonText: i18n.baseText('generic.cancel'),
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
inputValue: folder.name,
inputPattern: VALID_FOLDER_NAME_REGEX,
customClass: 'rename-folder-modal',
inputValidator: folderHelpers.validateFolderName,
},
);
const promptResponse = await promptResponsePromise;
@@ -985,6 +1024,14 @@ const renameFolder = async (folderId: string) => {
};
const createFolderInCurrent = async () => {
// Show the community plus enrollment modal if the user is in a community plan
if (isCommunity.value && canUserRegisterCommunityPlus.value) {
uiStore.openModalWithData({
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
data: { customHeading: i18n.baseText('folders.registeredCommunity.cta.heading') },
});
return;
}
if (!route.params.projectId) return;
const currentParent = currentFolder.value?.name || projectName.value;
if (!currentParent) return;
@@ -1013,19 +1060,22 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
const moveFolder = async (payload: {
folder: { id: string; name: string };
newParent: { id: string; name: string };
newParent: { id: string; name: string; type: 'folder' | 'project' };
}) => {
if (!route.params.projectId) return;
try {
await foldersStore.moveFolder(
route.params.projectId as string,
payload.folder.id,
payload.newParent.id,
payload.newParent.type === 'project' ? '0' : payload.newParent.id,
);
const isCurrentFolder = currentFolderId.value === payload.folder.id;
const newFolderURL = router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
params: { projectId: route.params.projectId, folderId: payload.newParent.id },
params: {
projectId: route.params.projectId,
folderId: payload.newParent.type === 'project' ? undefined : payload.newParent.id,
},
}).href;
if (isCurrentFolder) {
// If we just moved the current folder, automatically navigate to the new folder
@@ -1057,6 +1107,13 @@ const moveWorkflowToFolder = async (payload: {
name: string;
parentFolderId?: string;
}) => {
if (isCommunity.value && canUserRegisterCommunityPlus.value) {
uiStore.openModalWithData({
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
data: { customHeading: i18n.baseText('folders.registeredCommunity.cta.heading') },
});
return;
}
uiStore.openMoveToFolderModal(
'workflow',
{ id: payload.id, name: payload.name, parentFolderId: payload.parentFolderId },
@@ -1066,19 +1123,22 @@ const moveWorkflowToFolder = async (payload: {
const onWorkflowMoved = async (payload: {
workflow: { id: string; name: string; oldParentId: string };
newParent: { id: string; name: string };
newParent: { id: string; name: string; type: 'folder' | 'project' };
}) => {
if (!route.params.projectId) return;
try {
const newFolderURL = router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
params: { projectId: route.params.projectId, folderId: payload.newParent.id },
params: {
projectId: route.params.projectId,
folderId: payload.newParent.type === 'project' ? undefined : payload.newParent.id,
},
}).href;
const workflowResource = workflowsAndFolders.value.find(
(resource): resource is WorkflowListItem => resource.id === payload.workflow.id,
);
await workflowsStore.updateWorkflow(payload.workflow.id, {
parentFolderId: payload.newParent.id,
parentFolderId: payload.newParent.type === 'project' ? '0' : payload.newParent.id,
versionId: workflowResource?.versionId,
});
await fetchWorkflows();
@@ -1104,6 +1164,16 @@ const onWorkflowMoved = async (payload: {
toast.showError(error, i18n.baseText('folders.move.workflow.error.title'));
}
};
const onCreateWorkflowClick = () => {
void router.push({
name: VIEWS.NEW_WORKFLOW,
query: {
projectId: currentProject.value?.id,
parentFolderId: route.params.folderId as string,
},
});
};
</script>
<template>
@@ -1132,19 +1202,32 @@ const onWorkflowMoved = async (payload: {
<template #header>
<ProjectHeader @create-folder="createFolderInCurrent" />
</template>
<template v-if="showFolders" #add-button>
<N8nTooltip placement="top" :disabled="readOnlyEnv || !hasPermissionToCreateFolders">
<template v-if="foldersEnabled" #add-button>
<N8nTooltip
placement="top"
:disabled="!(isOverviewPage || (!readOnlyEnv && hasPermissionToCreateFolders))"
>
<template #content>
{{
currentParentName
? i18n.baseText('folders.add.to.parent.message', {
interpolate: { parent: currentParentName },
})
: i18n.baseText('folders.add.here.message')
}}
<span v-if="isOverviewPage">
<span v-if="teamProjectsEnabled">
{{ i18n.baseText('folders.add.overview.withProjects.message') }}
</span>
<span v-else>
{{ i18n.baseText('folders.add.overview.community.message') }}
</span>
</span>
<span v-else-if="!readOnlyEnv && hasPermissionToCreateFolders">
{{
currentParentName
? i18n.baseText('folders.add.to.parent.message', {
interpolate: { parent: currentParentName },
})
: i18n.baseText('folders.add.here.message')
}}
</span>
</template>
<N8nButton
size="large"
size="small"
icon="folder-plus"
type="tertiary"
data-test-id="add-folder-button"
@@ -1211,7 +1294,7 @@ const onWorkflowMoved = async (payload: {
/>
<WorkflowCard
v-else
data-test-id="resources-list-item"
data-test-id="resources-list-item-workflow"
class="mb-2xs"
:data="data as WorkflowResource"
:workflow-list-event-bus="workflowListEventBus"
@@ -1225,7 +1308,7 @@ const onWorkflowMoved = async (payload: {
/>
</template>
<template #empty>
<div class="text-center mt-s">
<div class="text-center mt-s" data-test-id="list-empty-state">
<N8nHeading tag="h2" size="xlarge" class="mb-2xs">
{{
currentUser.firstName
@@ -1308,6 +1391,34 @@ const onWorkflowMoved = async (payload: {
</N8nSelect>
</div>
</template>
<template #postamble>
<div
v-if="workflowsAndFolders.length === 0 && currentFolder && !hasFilters"
:class="$style['empty-folder-container']"
data-test-id="empty-folder-container"
>
<n8n-action-box
data-test-id="empty-folder-action-box"
:heading="
i18n.baseText('folders.empty.actionbox.title', {
interpolate: { folderName: currentFolder.name },
})
"
:button-text="i18n.baseText('generic.create.workflow')"
button-type="secondary"
:button-disabled="readOnlyEnv || !projectPermissions.workflow.create"
@click:button="onCreateWorkflowClick"
>
<template #disabledButtonTooltip>
{{
readOnlyEnv
? i18n.baseText('readOnlyEnv.cantAdd.workflow')
: i18n.baseText('generic.missing.permissions')
}}
</template></n8n-action-box
>
</div>
</template>
</ResourcesListLayout>
</template>
@@ -1358,7 +1469,8 @@ const onWorkflowMoved = async (payload: {
}
.add-folder-button {
width: 40px;
width: 30px;
height: 30px;
}
.breadcrumbs-container {
@@ -1374,6 +1486,12 @@ const onWorkflowMoved = async (payload: {
width: 400px;
}
}
.empty-folder-container {
button {
margin-top: var(--spacing-2xs);
}
}
</style>
<style lang="scss">