mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
fix(editor): Addressing internal testing feedback for folders (no-changelog) (#13997)
This commit is contained in:
committed by
GitHub
parent
305ea0fb32
commit
1f56a24bbd
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 * {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface UserAction {
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
type?: 'external-link';
|
||||
tooltip?: string;
|
||||
guard?: (user: IUser) => boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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 ?? '' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -567,6 +567,7 @@ const closeDialog = () => {
|
||||
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||
data: {
|
||||
closeCallback,
|
||||
customHeading: undefined,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
122
packages/frontend/editor-ui/src/composables/useFolders.test.ts
Normal file
122
packages/frontend/editor-ui/src/composables/useFolders.test.ts
Normal 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&ersand',
|
||||
"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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
46
packages/frontend/editor-ui/src/composables/useFolders.ts
Normal file
46
packages/frontend/editor-ui/src/composables/useFolders.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 that’s 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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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[]>([]);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user