mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +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) {
|
export function getFolderCard(name: string) {
|
||||||
return cy.getByTestId('folder-card-name').contains(name).closest('[data-test-id="folder-card"]');
|
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() {
|
export function getAddFolderButton() {
|
||||||
return cy.getByTestId('add-folder-button');
|
return cy.getByTestId('add-folder-button');
|
||||||
}
|
}
|
||||||
@@ -34,6 +59,10 @@ export function getHomeProjectBreadcrumb() {
|
|||||||
return getListBreadcrumbs().findChildByTestId('home-project');
|
return getListBreadcrumbs().findChildByTestId('home-project');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getListBreadcrumbItem(name: string) {
|
||||||
|
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item').contains(name);
|
||||||
|
}
|
||||||
|
|
||||||
export function getVisibleListBreadcrumbs() {
|
export function getVisibleListBreadcrumbs() {
|
||||||
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item');
|
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item');
|
||||||
}
|
}
|
||||||
@@ -94,13 +123,14 @@ export function getFolderCardActionToggle(folderName: string) {
|
|||||||
return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]');
|
return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFolderCardActionItem(name: string) {
|
export function getFolderCardActionItem(folderName: string, actionName: string) {
|
||||||
return cy
|
return getFolderCard(folderName)
|
||||||
.getByTestId('folder-card-actions')
|
.findChildByTestId('folder-card-actions')
|
||||||
|
.filter(':visible')
|
||||||
.find('span[aria-controls]')
|
.find('span[aria-controls]')
|
||||||
.invoke('attr', 'aria-controls')
|
.invoke('attr', 'aria-controls')
|
||||||
.then((popperId) => {
|
.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');
|
return cy.getByTestId('deleteFolder-modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMoveFolderModal() {
|
||||||
|
return cy.getByTestId('moveFolder-modal');
|
||||||
|
}
|
||||||
|
|
||||||
export function getDeleteRadioButton() {
|
export function getDeleteRadioButton() {
|
||||||
return cy.getByTestId('delete-content-radio');
|
return cy.getByTestId('delete-content-radio');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTransferContentRadioButton() {
|
||||||
|
return cy.getByTestId('transfer-content-radio');
|
||||||
|
}
|
||||||
|
|
||||||
export function getConfirmDeleteInput() {
|
export function getConfirmDeleteInput() {
|
||||||
return getFolderDeleteModal().findChildByTestId('delete-data-input').find('input');
|
return getFolderDeleteModal().findChildByTestId('delete-data-input').find('input');
|
||||||
}
|
}
|
||||||
@@ -119,6 +157,61 @@ export function getConfirmDeleteInput() {
|
|||||||
export function getDeleteFolderModalConfirmButton() {
|
export function getDeleteFolderModalConfirmButton() {
|
||||||
return getFolderDeleteModal().findChildByTestId('confirm-delete-folder-button');
|
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
|
* Actions
|
||||||
*/
|
*/
|
||||||
@@ -136,8 +229,46 @@ export function createFolderFromListHeaderButton(folderName: string) {
|
|||||||
createNewFolder(folderName);
|
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) {
|
export function createFolderFromProjectHeader(folderName: string) {
|
||||||
getPersonalProjectMenuItem().click();
|
|
||||||
getAddResourceDropdown().click();
|
getAddResourceDropdown().click();
|
||||||
cy.getByTestId('action-folder').click();
|
cy.getByTestId('action-folder').click();
|
||||||
createNewFolder(folderName);
|
createNewFolder(folderName);
|
||||||
@@ -151,7 +282,7 @@ export function createFolderFromListDropdown(folderName: string) {
|
|||||||
|
|
||||||
export function createFolderFromCardActions(parentName: string, folderName: string) {
|
export function createFolderFromCardActions(parentName: string, folderName: string) {
|
||||||
getFolderCardActionToggle(parentName).click();
|
getFolderCardActionToggle(parentName).click();
|
||||||
getFolderCardActionItem('create').click();
|
getFolderCardActionItem(parentName, 'create').click();
|
||||||
createNewFolder(folderName);
|
createNewFolder(folderName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +295,7 @@ export function renameFolderFromListActions(folderName: string, newName: string)
|
|||||||
|
|
||||||
export function renameFolderFromCardActions(folderName: string, newName: string) {
|
export function renameFolderFromCardActions(folderName: string, newName: string) {
|
||||||
getFolderCardActionToggle(folderName).click();
|
getFolderCardActionToggle(folderName).click();
|
||||||
getFolderCardActionItem('rename').click();
|
getFolderCardActionItem(folderName, 'rename').click();
|
||||||
renameFolder(newName);
|
renameFolder(newName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,9 +325,63 @@ export function deleteFolderWithContentsFromListDropdown(folderName: string) {
|
|||||||
|
|
||||||
export function deleteFolderWithContentsFromCardDropdown(folderName: string) {
|
export function deleteFolderWithContentsFromCardDropdown(folderName: string) {
|
||||||
getFolderCardActionToggle(folderName).click();
|
getFolderCardActionToggle(folderName).click();
|
||||||
getFolderCardActionItem('delete').click();
|
getFolderCardActionItem(folderName, 'delete').click();
|
||||||
confirmFolderDelete(folderName);
|
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
|
* Utils
|
||||||
*/
|
*/
|
||||||
@@ -240,3 +425,34 @@ function confirmFolderDelete(folderName: string) {
|
|||||||
cy.wait('@deleteFolder');
|
cy.wait('@deleteFolder');
|
||||||
successToast().contains('Folder deleted').should('exist');
|
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() {
|
export function getWorkflowCards() {
|
||||||
return cy.getByTestId('resources-list-item');
|
return cy.getByTestId('resources-list-item-workflow');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkflowCard(workflowName: string) {
|
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) {
|
export function getWorkflowCardContent(workflowName: string) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const switchBetweenEditorAndWorkflowlist = () => {
|
|||||||
cy.getByTestId('menu-item').first().click();
|
cy.getByTestId('menu-item').first().click();
|
||||||
cy.wait(['@getUsers', '@getWorkflows', '@getActiveWorkflows', '@getProjects']);
|
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().first().should('be.visible');
|
||||||
workflowPage.getters.canvasNodes().last().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().should('have.length', 3);
|
||||||
workflowsPage.getters
|
workflowsPage.getters
|
||||||
.workflowCards()
|
.workflowCards()
|
||||||
.filter(':has(.n8n-badge:contains("Project"))')
|
.filter(':has([data-test-id="workflow-card-breadcrumbs"]:contains("Project"))')
|
||||||
.should('have.length', 2);
|
.should('have.length', 2);
|
||||||
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
workflowsPage.getters.workflowCardActions('Workflow in Home project').click();
|
||||||
workflowsPage.getters.workflowMoveButton().click();
|
workflowsPage.getters.workflowMoveButton().click();
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import {
|
|||||||
createFolderFromListHeaderButton,
|
createFolderFromListHeaderButton,
|
||||||
createFolderFromProjectHeader,
|
createFolderFromProjectHeader,
|
||||||
createFolderInsideFolder,
|
createFolderInsideFolder,
|
||||||
|
createNewProject,
|
||||||
|
createWorkflowFromEmptyState,
|
||||||
|
createWorkflowFromListDropdown,
|
||||||
|
createWorkflowFromProjectHeader,
|
||||||
|
deleteAndTransferFolderContentsFromCardDropdown,
|
||||||
|
deleteAndTransferFolderContentsFromListDropdown,
|
||||||
deleteEmptyFolderFromCardDropdown,
|
deleteEmptyFolderFromCardDropdown,
|
||||||
deleteEmptyFolderFromListDropdown,
|
deleteEmptyFolderFromListDropdown,
|
||||||
deleteFolderWithContentsFromCardDropdown,
|
deleteFolderWithContentsFromCardDropdown,
|
||||||
@@ -14,14 +20,27 @@ import {
|
|||||||
getFolderCardActionItem,
|
getFolderCardActionItem,
|
||||||
getFolderCardActionToggle,
|
getFolderCardActionToggle,
|
||||||
getFolderCards,
|
getFolderCards,
|
||||||
|
getFolderEmptyState,
|
||||||
getHomeProjectBreadcrumb,
|
getHomeProjectBreadcrumb,
|
||||||
|
getListBreadcrumbItem,
|
||||||
getListBreadcrumbs,
|
getListBreadcrumbs,
|
||||||
getMainBreadcrumbsEllipsis,
|
getMainBreadcrumbsEllipsis,
|
||||||
getMainBreadcrumbsEllipsisMenuItems,
|
getMainBreadcrumbsEllipsisMenuItems,
|
||||||
|
getNewFolderModalErrorMessage,
|
||||||
|
getNewFolderNameInput,
|
||||||
getOverviewMenuItem,
|
getOverviewMenuItem,
|
||||||
getPersonalProjectMenuItem,
|
getPersonalProjectMenuItem,
|
||||||
|
getProjectEmptyState,
|
||||||
|
getProjectMenuItem,
|
||||||
getVisibleListBreadcrumbs,
|
getVisibleListBreadcrumbs,
|
||||||
|
getWorkflowCard,
|
||||||
|
getWorkflowCardBreadcrumbs,
|
||||||
|
getWorkflowCardBreadcrumbsEllipsis,
|
||||||
|
getWorkflowCards,
|
||||||
goToPersonalProject,
|
goToPersonalProject,
|
||||||
|
moveFolderFromFolderCardActions,
|
||||||
|
moveFolderFromListActions,
|
||||||
|
moveWorkflowToFolder,
|
||||||
renameFolderFromCardActions,
|
renameFolderFromCardActions,
|
||||||
renameFolderFromListActions,
|
renameFolderFromListActions,
|
||||||
} from '../composables/folders';
|
} from '../composables/folders';
|
||||||
@@ -44,6 +63,7 @@ describe('Folders', () => {
|
|||||||
|
|
||||||
describe('Create and navigate folders', () => {
|
describe('Create and navigate folders', () => {
|
||||||
it('should create folder from the project header', () => {
|
it('should create folder from the project header', () => {
|
||||||
|
getPersonalProjectMenuItem().click();
|
||||||
createFolderFromProjectHeader('My Folder');
|
createFolderFromProjectHeader('My Folder');
|
||||||
getFolderCards().should('have.length.greaterThan', 0);
|
getFolderCards().should('have.length.greaterThan', 0);
|
||||||
// Clicking on the success toast should navigate to the folder
|
// Clicking on the success toast should navigate to the folder
|
||||||
@@ -51,6 +71,33 @@ describe('Folders', () => {
|
|||||||
getCurrentBreadcrumb().should('contain.text', 'My Folder');
|
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', () => {
|
it('should create folder from the list header button', () => {
|
||||||
goToPersonalProject();
|
goToPersonalProject();
|
||||||
// First create a folder so list appears
|
// First create a folder so list appears
|
||||||
@@ -78,9 +125,9 @@ describe('Folders', () => {
|
|||||||
getFolderCard('Created from card dropdown').should('exist');
|
getFolderCard('Created from card dropdown').should('exist');
|
||||||
createFolderFromCardActions('Created from card dropdown', 'Child Folder');
|
createFolderFromCardActions('Created from card dropdown', 'Child Folder');
|
||||||
successToast().should('exist');
|
successToast().should('exist');
|
||||||
// Open parent folder to see the new child folder
|
// Should be automatically navigated to the new folder
|
||||||
getFolderCard('Created from card dropdown').click();
|
|
||||||
getFolderCard('Child Folder').should('exist');
|
getFolderCard('Child Folder').should('exist');
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Created from card dropdown');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should navigate folders using breadcrumbs and dropdown menu', () => {
|
it('should navigate folders using breadcrumbs and dropdown menu', () => {
|
||||||
@@ -88,7 +135,7 @@ describe('Folders', () => {
|
|||||||
createFolderFromProjectHeader('Navigate Test');
|
createFolderFromProjectHeader('Navigate Test');
|
||||||
// Open folder using menu item
|
// Open folder using menu item
|
||||||
getFolderCardActionToggle('Navigate Test').click();
|
getFolderCardActionToggle('Navigate Test').click();
|
||||||
getFolderCardActionItem('open').click();
|
getFolderCardActionItem('Navigate Test', 'open').click();
|
||||||
getCurrentBreadcrumb().should('contain.text', 'Navigate Test');
|
getCurrentBreadcrumb().should('contain.text', 'Navigate Test');
|
||||||
// Create new child folder and navigate to it
|
// Create new child folder and navigate to it
|
||||||
createFolderFromListHeaderButton('Child Folder');
|
createFolderFromListHeaderButton('Child Folder');
|
||||||
@@ -165,12 +212,72 @@ describe('Folders', () => {
|
|||||||
|
|
||||||
// In personal, we should see previously created folders
|
// In personal, we should see previously created folders
|
||||||
getPersonalProjectMenuItem().click();
|
getPersonalProjectMenuItem().click();
|
||||||
|
getAddResourceDropdown().click();
|
||||||
cy.getByTestId('action-folder').should('exist');
|
cy.getByTestId('action-folder').should('exist');
|
||||||
createFolderFromProjectHeader('Personal Folder');
|
createFolderFromProjectHeader('Personal Folder');
|
||||||
getFolderCards().should('exist');
|
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', () => {
|
describe('Rename and delete folders', () => {
|
||||||
it('should rename folder from main dropdown', () => {
|
it('should rename folder from main dropdown', () => {
|
||||||
goToPersonalProject();
|
goToPersonalProject();
|
||||||
@@ -224,6 +331,175 @@ describe('Folders', () => {
|
|||||||
deleteFolderWithContentsFromCardDropdown('I also have family');
|
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');
|
cy.getByTestId('add-resource-workflow').should('be.visible');
|
||||||
return cy.getByTestId('add-resource-workflow');
|
return cy.getByTestId('add-resource-workflow');
|
||||||
},
|
},
|
||||||
workflowCards: () => cy.getByTestId('resources-list-item'),
|
workflowCards: () => cy.getByTestId('resources-list-item-workflow'),
|
||||||
workflowCard: (workflowName: string) =>
|
workflowCard: (workflowName: string) =>
|
||||||
this.getters
|
this.getters
|
||||||
.workflowCards()
|
.workflowCards()
|
||||||
.contains(workflowName)
|
.contains(workflowName)
|
||||||
.parents('[data-test-id="resources-list-item"]'),
|
.parents('[data-test-id="resources-list-item-workflow"]'),
|
||||||
workflowTags: (workflowName: string) =>
|
workflowTags: (workflowName: string) =>
|
||||||
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-tags'),
|
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-tags'),
|
||||||
workflowCardContent: (workflowName: string) =>
|
workflowCardContent: (workflowName: string) =>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ withDefaults(defineProps<ActionBoxProps>(), {
|
|||||||
<slot name="heading">{{ heading }}</slot>
|
<slot name="heading">{{ heading }}</slot>
|
||||||
</N8nHeading>
|
</N8nHeading>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.description" @click="$emit('descriptionClick', $event)">
|
<div v-if="description" :class="$style.description" @click="$emit('descriptionClick', $event)">
|
||||||
<N8nText color="text-base">
|
<N8nText color="text-base">
|
||||||
<slot name="description">
|
<slot name="description">
|
||||||
<span v-n8n-html="description"></span>
|
<span v-n8n-html="description"></span>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Props = {
|
|||||||
loadingSkeletonRows?: number;
|
loadingSkeletonRows?: number;
|
||||||
separator?: string;
|
separator?: string;
|
||||||
highlightLastItem?: boolean;
|
highlightLastItem?: boolean;
|
||||||
|
hiddenItemsTrigger?: 'hover' | 'click';
|
||||||
// Setting this to true will show the ellipsis even if there are no hidden items
|
// Setting this to true will show the ellipsis even if there are no hidden items
|
||||||
pathTruncated?: boolean;
|
pathTruncated?: boolean;
|
||||||
};
|
};
|
||||||
@@ -40,6 +41,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
separator: '/',
|
separator: '/',
|
||||||
highlightLastItem: true,
|
highlightLastItem: true,
|
||||||
isPathTruncated: false,
|
isPathTruncated: false,
|
||||||
|
hiddenItemsTrigger: 'click',
|
||||||
});
|
});
|
||||||
|
|
||||||
const loadedHiddenItems = ref<PathItem[]>([]);
|
const loadedHiddenItems = ref<PathItem[]>([]);
|
||||||
@@ -170,7 +172,8 @@ const handleTooltipClose = () => {
|
|||||||
v-else
|
v-else
|
||||||
:popper-class="$style.tooltip"
|
:popper-class="$style.tooltip"
|
||||||
:disabled="dropdownDisabled"
|
:disabled="dropdownDisabled"
|
||||||
trigger="click"
|
:trigger="hiddenItemsTrigger"
|
||||||
|
placement="bottom"
|
||||||
@before-show="handleTooltipShow"
|
@before-show="handleTooltipShow"
|
||||||
@hide="handleTooltipClose"
|
@hide="handleTooltipClose"
|
||||||
>
|
>
|
||||||
@@ -313,6 +316,7 @@ const handleTooltipClose = () => {
|
|||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
padding: var(--spacing-xs) var(--spacing-2xs);
|
padding: var(--spacing-xs) var(--spacing-2xs);
|
||||||
|
text-align: center;
|
||||||
& > div {
|
& > div {
|
||||||
color: var(--color-text-lighter);
|
color: var(--color-text-lighter);
|
||||||
span {
|
span {
|
||||||
@@ -352,6 +356,7 @@ const handleTooltipClose = () => {
|
|||||||
color: var(--color-text-base);
|
color: var(--color-text-base);
|
||||||
font-size: var(--font-size-2xs);
|
font-size: var(--font-size-2xs);
|
||||||
font-weight: var(--font-weight-bold);
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: var(--font-line-heigh-xsmall);
|
||||||
}
|
}
|
||||||
|
|
||||||
.item a:hover * {
|
.item a:hover * {
|
||||||
|
|||||||
@@ -616,6 +616,7 @@
|
|||||||
--font-size-xl: 1.25rem;
|
--font-size-xl: 1.25rem;
|
||||||
--font-size-2xl: 1.75rem;
|
--font-size-2xl: 1.75rem;
|
||||||
|
|
||||||
|
--font-line-heigh-xsmall: 1;
|
||||||
--font-line-height-compact: 1.25;
|
--font-line-height-compact: 1.25;
|
||||||
--font-line-height-regular: 1.3;
|
--font-line-height-regular: 1.3;
|
||||||
--font-line-height-loose: 1.35;
|
--font-line-height-loose: 1.35;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface UserAction {
|
|||||||
value: string;
|
value: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
type?: 'external-link';
|
type?: 'external-link';
|
||||||
|
tooltip?: string;
|
||||||
guard?: (user: IUser) => boolean;
|
guard?: (user: IUser) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ export interface IWorkflowDb {
|
|||||||
versionId: string;
|
versionId: string;
|
||||||
usedCredentials?: IUsedCredential[];
|
usedCredentials?: IUsedCredential[];
|
||||||
meta?: WorkflowMetadata;
|
meta?: WorkflowMetadata;
|
||||||
|
parentFolder?: { id: string; name: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// For workflow list we don't need the full workflow data
|
// 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'
|
'nodes' | 'connections' | 'settings' | 'pinData' | 'usedCredentials' | 'meta'
|
||||||
> & {
|
> & {
|
||||||
resource: 'workflow';
|
resource: 'workflow';
|
||||||
parentFolder?: { id: string; name: string };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FolderShortInfo = {
|
export type FolderShortInfo = {
|
||||||
@@ -363,6 +363,10 @@ export interface FolderListItem extends BaseFolderItem {
|
|||||||
resource: 'folder';
|
resource: 'folder';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangeLocationSearchResult extends BaseFolderItem {
|
||||||
|
resource: 'folder' | 'project';
|
||||||
|
}
|
||||||
|
|
||||||
export type FolderPathItem = PathItem & { parentFolder?: string };
|
export type FolderPathItem = PathItem & { parentFolder?: string };
|
||||||
|
|
||||||
export type WorkflowListResource = WorkflowListItem | FolderListItem;
|
export type WorkflowListResource = WorkflowListItem | FolderListItem;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
ChangeLocationSearchResult,
|
||||||
FolderCreateResponse,
|
FolderCreateResponse,
|
||||||
FolderListItem,
|
|
||||||
FolderTreeResponseItem,
|
FolderTreeResponseItem,
|
||||||
IExecutionResponse,
|
IExecutionResponse,
|
||||||
IExecutionsCurrentSummaryExtended,
|
IExecutionsCurrentSummaryExtended,
|
||||||
@@ -146,8 +146,8 @@ export async function getProjectFolders(
|
|||||||
excludeFolderIdAndDescendants?: string;
|
excludeFolderIdAndDescendants?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
},
|
},
|
||||||
): Promise<FolderListItem[]> {
|
): Promise<ChangeLocationSearchResult[]> {
|
||||||
const res = await getFullApiResponse<FolderListItem[]>(
|
const res = await getFullApiResponse<ChangeLocationSearchResult[]>(
|
||||||
context,
|
context,
|
||||||
'GET',
|
'GET',
|
||||||
`/projects/${projectId}/folders`,
|
`/projects/${projectId}/folders`,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const props = defineProps<{
|
|||||||
modalName: string;
|
modalName: string;
|
||||||
data?: {
|
data?: {
|
||||||
closeCallback?: () => void;
|
closeCallback?: () => void;
|
||||||
|
customHeading?: string;
|
||||||
};
|
};
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ const confirm = async () => {
|
|||||||
<N8nBadge>{{ i18n.baseText('communityPlusModal.badge') }}</N8nBadge>
|
<N8nBadge>{{ i18n.baseText('communityPlusModal.badge') }}</N8nBadge>
|
||||||
</p>
|
</p>
|
||||||
<N8nText tag="h1" align="center" size="xlarge" class="mb-m">{{
|
<N8nText tag="h1" align="center" size="xlarge" class="mb-m">{{
|
||||||
i18n.baseText('communityPlusModal.title')
|
data?.customHeading ?? i18n.baseText('communityPlusModal.title')
|
||||||
}}</N8nText>
|
}}</N8nText>
|
||||||
<N8nText tag="p">{{ i18n.baseText('communityPlusModal.description') }}</N8nText>
|
<N8nText tag="p">{{ i18n.baseText('communityPlusModal.description') }}</N8nText>
|
||||||
<ul :class="$style.features">
|
<ul :class="$style.features">
|
||||||
@@ -114,6 +115,13 @@ const confirm = async () => {
|
|||||||
{{ i18n.baseText('communityPlusModal.features.third.description') }}
|
{{ i18n.baseText('communityPlusModal.features.third.description') }}
|
||||||
</N8nText>
|
</N8nText>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<i> 📁</i>
|
||||||
|
<N8nText>
|
||||||
|
<strong>{{ i18n.baseText('communityPlusModal.features.fourth.title') }}</strong>
|
||||||
|
{{ i18n.baseText('communityPlusModal.features.fourth.description') }}
|
||||||
|
</N8nText>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<N8nFormInput
|
<N8nFormInput
|
||||||
id="email"
|
id="email"
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
|
|||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import type { FolderListItem } from '@/Interface';
|
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modalName: string;
|
modalName: string;
|
||||||
@@ -32,12 +32,7 @@ const projectsStore = useProjectsStore();
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const operation = ref('');
|
const operation = ref('');
|
||||||
const deleteConfirmText = ref('');
|
const deleteConfirmText = ref('');
|
||||||
const selectedFolder = ref<{ id: string; name: string } | null>(null);
|
const selectedFolder = ref<{ id: string; name: string; type: 'folder' | 'project' } | null>(null);
|
||||||
const projectFolders = ref<FolderListItem[]>([]);
|
|
||||||
|
|
||||||
const currentFolder = computed(() => {
|
|
||||||
return projectFolders.value.find((folder) => folder.id === props.activeId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const folderToDelete = computed(() => {
|
const folderToDelete = computed(() => {
|
||||||
if (!props.activeId) return null;
|
if (!props.activeId) return null;
|
||||||
@@ -72,6 +67,14 @@ const enabled = computed(() => {
|
|||||||
return false;
|
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 folderContentWarningMessage = computed(() => {
|
||||||
const folderCount = props.data.content.subFolderCount ?? 0;
|
const folderCount = props.data.content.subFolderCount ?? 0;
|
||||||
const workflowCount = props.data.content.workflowCount ?? 0;
|
const workflowCount = props.data.content.workflowCount ?? 0;
|
||||||
@@ -102,11 +105,10 @@ async function onSubmit() {
|
|||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
await foldersStore.deleteFolder(
|
const newParentId =
|
||||||
route.params.projectId as string,
|
selectedFolder.value?.type === 'project' ? '0' : (selectedFolder.value?.id ?? undefined);
|
||||||
props.activeId,
|
|
||||||
selectedFolder.value?.id ?? undefined,
|
await foldersStore.deleteFolder(route.params.projectId as string, props.activeId, newParentId);
|
||||||
);
|
|
||||||
|
|
||||||
let message = '';
|
let message = '';
|
||||||
if (selectedFolder.value) {
|
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;
|
selectedFolder.value = payload;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -142,7 +144,7 @@ const onFolderSelected = (payload: { id: string; name: string }) => {
|
|||||||
:name="modalName"
|
:name="modalName"
|
||||||
:title="title"
|
:title="title"
|
||||||
:center="true"
|
:center="true"
|
||||||
width="520"
|
width="600"
|
||||||
:event-bus="modalBus"
|
:event-bus="modalBus"
|
||||||
@enter="onSubmit"
|
@enter="onSubmit"
|
||||||
>
|
>
|
||||||
@@ -163,7 +165,14 @@ const onFolderSelected = (payload: { id: string; name: string }) => {
|
|||||||
label="transfer"
|
label="transfer"
|
||||||
@update:model-value="operation = '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>
|
</el-radio>
|
||||||
<div v-if="operation === 'transfer'" :class="$style.optionInput">
|
<div v-if="operation === 'transfer'" :class="$style.optionInput">
|
||||||
<n8n-text color="text-dark">{{
|
<n8n-text color="text-dark">{{
|
||||||
@@ -173,8 +182,8 @@ const onFolderSelected = (payload: { id: string; name: string }) => {
|
|||||||
v-if="projectsStore.currentProject"
|
v-if="projectsStore.currentProject"
|
||||||
:current-folder-id="props.activeId"
|
:current-folder-id="props.activeId"
|
||||||
:current-project-id="projectsStore.currentProject?.id"
|
:current-project-id="projectsStore.currentProject?.id"
|
||||||
:parent-folder-id="currentFolder?.parentFolder?.id"
|
:parent-folder-id="folderToDelete?.parentFolder"
|
||||||
@folder:selected="onFolderSelected"
|
@location:selected="onFolderSelected"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<el-radio
|
<el-radio
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const onAction = (action: string) => {
|
|||||||
<n8n-action-toggle
|
<n8n-action-toggle
|
||||||
v-if="breadcrumbs.visibleItems"
|
v-if="breadcrumbs.visibleItems"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
|
:class="$style['action-toggle']"
|
||||||
theme="dark"
|
theme="dark"
|
||||||
data-test-id="folder-breadcrumbs-actions"
|
data-test-id="folder-breadcrumbs-actions"
|
||||||
@action="onAction"
|
@action="onAction"
|
||||||
@@ -78,6 +79,12 @@ const onAction = (action: string) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-toggle {
|
||||||
|
span[role='button'] {
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.home-project {
|
.home-project {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import type { FolderListItem } from '@/Interface';
|
import type { ChangeLocationSearchResult } from '@/Interface';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
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 { 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.
|
* 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<{
|
const emit = defineEmits<{
|
||||||
'folder:selected': [value: { id: string; name: string }];
|
'location:selected': [value: { id: string; name: string; type: 'folder' | 'project' }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
const foldersStore = useFoldersStore();
|
const foldersStore = useFoldersStore();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
|
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
|
||||||
const selectedFolderId = ref<string | null>(null);
|
const selectedFolderId = ref<string | null>(null);
|
||||||
const availableFolders = ref<FolderListItem[]>([]);
|
const availableLocations = ref<ChangeLocationSearchResult[]>([]);
|
||||||
const loading = ref(false);
|
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) {
|
if (!query) {
|
||||||
availableFolders.value = [];
|
availableLocations.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -48,19 +72,41 @@ const fetchAvailableFolders = async (query?: string) => {
|
|||||||
{ name: query ?? undefined },
|
{ name: query ?? undefined },
|
||||||
);
|
);
|
||||||
if (!props.parentFolderId) {
|
if (!props.parentFolderId) {
|
||||||
availableFolders.value = folders;
|
availableLocations.value = folders;
|
||||||
} else {
|
} 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;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFolderSelected = (folderId: string) => {
|
const onFolderSelected = (folderId: string) => {
|
||||||
const selectedFolder = availableFolders.value.find((folder) => folder.id === folderId);
|
const selectedFolder = availableLocations.value.find((folder) => folder.id === folderId);
|
||||||
if (!selectedFolder) {
|
if (!selectedFolder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
emit('folder:selected', { id: folderId, name: selectedFolder.name });
|
emit('location:selected', {
|
||||||
|
id: folderId,
|
||||||
|
name: selectedFolder.name,
|
||||||
|
type: selectedFolder.resource,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -71,22 +117,35 @@ const onFolderSelected = (folderId: string) => {
|
|||||||
v-model="selectedFolderId"
|
v-model="selectedFolderId"
|
||||||
:filterable="true"
|
:filterable="true"
|
||||||
:remote="true"
|
:remote="true"
|
||||||
:remote-method="fetchAvailableFolders"
|
:remote-method="fetchAvailableLocations"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:placeholder="i18n.baseText('folders.move.modal.select.placeholder')"
|
:placeholder="i18n.baseText('folders.move.modal.select.placeholder')"
|
||||||
|
:no-data-text="i18n.baseText('folders.move.modal.no.data.label')"
|
||||||
option-label="name"
|
option-label="name"
|
||||||
option-value="id"
|
option-value="id"
|
||||||
@update:model-value="onFolderSelected"
|
@update:model-value="onFolderSelected"
|
||||||
>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<N8nIcon icon="search" />
|
||||||
|
</template>
|
||||||
<N8nOption
|
<N8nOption
|
||||||
v-for="folder in availableFolders"
|
v-for="location in availableLocations"
|
||||||
:key="folder.id"
|
:key="location.id"
|
||||||
:value="folder.id"
|
:value="location.id"
|
||||||
:label="folder.name"
|
:label="location.name"
|
||||||
|
data-test-id="move-to-folder-option"
|
||||||
>
|
>
|
||||||
<div :class="$style['folder-select-item']">
|
<div :class="$style['folder-select-item']">
|
||||||
<n8n-icon :class="$style['folder-icon']" icon="folder" />
|
<ProjectIcon
|
||||||
<span :class="$style['folder-name']"> {{ folder.name }}</span>
|
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>
|
</div>
|
||||||
</N8nOption>
|
</N8nOption>
|
||||||
</N8nSelect>
|
</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;
|
selectedFolder.value = payload;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ const onSubmit = () => {
|
|||||||
:current-project-id="projectsStore.currentProject?.id"
|
:current-project-id="projectsStore.currentProject?.id"
|
||||||
:parent-folder-id="props.data.resource.parentFolderId"
|
:parent-folder-id="props.data.resource.parentFolderId"
|
||||||
:exclude-only-parent="props.data.resourceType === 'workflow'"
|
:exclude-only-parent="props.data.resourceType === 'workflow'"
|
||||||
@folder:selected="onFolderSelected"
|
@location:selected="onFolderSelected"
|
||||||
/>
|
/>
|
||||||
<p
|
<p
|
||||||
v-if="props.data.resourceType === 'folder'"
|
v-if="props.data.resourceType === 'folder'"
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import { useTelemetry } from '@/composables/useTelemetry';
|
|||||||
import type { BaseTextKey } from '@/plugins/i18n';
|
import type { BaseTextKey } from '@/plugins/i18n';
|
||||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||||
|
import { ProjectTypes } from '@/types/projects.types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -200,6 +201,26 @@ const workflowTagIds = computed(() => {
|
|||||||
return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id));
|
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(
|
watch(
|
||||||
() => props.id,
|
() => props.id,
|
||||||
() => {
|
() => {
|
||||||
@@ -533,16 +554,22 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
|||||||
let toastTitle = locale.baseText('workflows.create.personal.toast.title');
|
let toastTitle = locale.baseText('workflows.create.personal.toast.title');
|
||||||
let toastText = locale.baseText('workflows.create.personal.toast.text');
|
let toastText = locale.baseText('workflows.create.personal.toast.text');
|
||||||
|
|
||||||
if (
|
if (projectsStore.currentProject) {
|
||||||
projectsStore.currentProject &&
|
if (currentFolder.value) {
|
||||||
projectsStore.currentProject.id !== projectsStore.personalProject?.id
|
toastTitle = locale.baseText('workflows.create.folder.toast.title', {
|
||||||
) {
|
interpolate: {
|
||||||
toastTitle = locale.baseText('workflows.create.project.toast.title', {
|
projectName: currentProjectName.value ?? '',
|
||||||
interpolate: { projectName: projectsStore.currentProject.name ?? '' },
|
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', {
|
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,
|
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
data: {
|
data: {
|
||||||
closeCallback,
|
closeCallback,
|
||||||
|
customHeading: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const showSettings = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||||
|
|
||||||
const showFolders = computed(() => {
|
const showFolders = computed(() => {
|
||||||
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
|
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
|
||||||
});
|
});
|
||||||
@@ -189,7 +190,7 @@ const onSelect = (action: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
padding: var(--spacing-2xs) 0 var(--spacing-l);
|
padding: var(--spacing-2xs) 0 var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mixins.breakpoint('xs-only') {
|
@include mixins.breakpoint('xs-only') {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import {
|
import {
|
||||||
DUPLICATE_MODAL_KEY,
|
DUPLICATE_MODAL_KEY,
|
||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
@@ -26,6 +26,9 @@ import { ResourceType } from '@/utils/projects.utils';
|
|||||||
import type { EventBus } from '@n8n/utils/event-bus';
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
import type { WorkflowResource } from './layouts/ResourcesListLayout.vue';
|
import type { WorkflowResource } from './layouts/ResourcesListLayout.vue';
|
||||||
import type { IUser } from 'n8n-workflow';
|
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 = {
|
const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||||
OPEN: 'open',
|
OPEN: 'open',
|
||||||
@@ -68,15 +71,56 @@ const uiStore = useUIStore();
|
|||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
const workflowsStore = useWorkflowsStore();
|
const workflowsStore = useWorkflowsStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
|
const foldersStore = useFoldersStore();
|
||||||
|
|
||||||
|
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
|
||||||
|
|
||||||
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
||||||
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||||
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
|
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
|
||||||
|
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
|
||||||
|
|
||||||
const showFolders = computed(() => {
|
const showFolders = computed(() => {
|
||||||
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
|
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 actions = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -236,6 +280,17 @@ async function deleteWorkflow() {
|
|||||||
emit('workflow:deleted');
|
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() {
|
function moveResource() {
|
||||||
uiStore.openModalWithData({
|
uiStore.openModalWithData({
|
||||||
name: PROJECT_MOVE_RESOURCE_MODAL,
|
name: PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
@@ -251,6 +306,12 @@ function moveResource() {
|
|||||||
const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
||||||
emit('workflow:active-toggle', value);
|
emit('workflow:active-toggle', value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onBreadcrumbItemClick = async (item: PathItem) => {
|
||||||
|
if (item.href) {
|
||||||
|
await router.push(item.href);
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -289,7 +350,33 @@ const emitWorkflowActiveToggle = (value: { id: string; active: boolean }) => {
|
|||||||
</div>
|
</div>
|
||||||
<template #append>
|
<template #append>
|
||||||
<div :class="$style.cardActions" @click.stop>
|
<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
|
<ProjectCardBadge
|
||||||
|
v-else
|
||||||
:class="$style.cardBadge"
|
:class="$style.cardBadge"
|
||||||
:resource="data"
|
:resource="data"
|
||||||
:resource-type="ResourceType.Workflow"
|
:resource-type="ResourceType.Workflow"
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ onBeforeMount(async () => {
|
|||||||
<n8n-button
|
<n8n-button
|
||||||
icon="filter"
|
icon="filter"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
|
size="small"
|
||||||
:active="hasFilters"
|
:active="hasFilters"
|
||||||
:class="{
|
:class="{
|
||||||
[$style['filter-button']]: true,
|
[$style['filter-button']]: true,
|
||||||
@@ -165,7 +166,8 @@ onBeforeMount(async () => {
|
|||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
.filter-button {
|
.filter-button {
|
||||||
height: 40px;
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
&.no-label {
|
&.no-label {
|
||||||
|
|||||||
@@ -543,6 +543,7 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
:model-value="filtersModel.search"
|
:model-value="filtersModel.search"
|
||||||
:class="$style.search"
|
:class="$style.search"
|
||||||
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
|
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
|
||||||
|
size="small"
|
||||||
clearable
|
clearable
|
||||||
data-test-id="resources-list-search"
|
data-test-id="resources-list-search"
|
||||||
@update:model-value="onSearch"
|
@update:model-value="onSearch"
|
||||||
@@ -552,7 +553,7 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</n8n-input>
|
</n8n-input>
|
||||||
<div :class="$style['sort-and-filter']">
|
<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
|
<n8n-option
|
||||||
v-for="sortOption in sortOptions"
|
v-for="sortOption in sortOptions"
|
||||||
:key="sortOption"
|
:key="sortOption"
|
||||||
@@ -660,7 +661,12 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
</n8n-datatable>
|
</n8n-datatable>
|
||||||
</div>
|
</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) }}
|
{{ i18n.baseText(`${resourceKey}.noResults` as BaseTextKey) }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
|
|
||||||
@@ -684,14 +690,14 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: 1fr max-content max-content max-content;
|
grid-auto-columns: 1fr max-content max-content max-content;
|
||||||
gap: var(--spacing-2xs);
|
gap: var(--spacing-4xs);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: end;
|
justify-content: end;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
.sort-and-filter {
|
.sort-and-filter {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: var(--spacing-2xs);
|
gap: var(--spacing-4xs);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -707,7 +713,7 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
justify-self: end;
|
justify-self: end;
|
||||||
|
|
||||||
input {
|
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 cloudPlanStore = useCloudPlanStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|||||||
@@ -430,11 +430,28 @@ export const MODAL_CANCEL = 'cancel';
|
|||||||
export const MODAL_CONFIRM = 'confirm';
|
export const MODAL_CONFIRM = 'confirm';
|
||||||
export const MODAL_CLOSE = 'close';
|
export const MODAL_CLOSE = 'close';
|
||||||
|
|
||||||
/**
|
export const ILLEGAL_FOLDER_CHARACTERS = [
|
||||||
* Invalid characters: \/:*?"<>|
|
'[',
|
||||||
* Invalid name: empty or only dots
|
']',
|
||||||
*/
|
'^',
|
||||||
export const VALID_FOLDER_NAME_REGEX = /^(?!\.+$)(?!\s+$)[^\\/:*?"<>|]{1,100}$/;
|
'\\',
|
||||||
|
'/',
|
||||||
|
':',
|
||||||
|
'*',
|
||||||
|
'?',
|
||||||
|
'"',
|
||||||
|
'<',
|
||||||
|
'>',
|
||||||
|
'|',
|
||||||
|
];
|
||||||
|
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 =
|
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,}))$/;
|
/^(([^<>()[\]\\.,;:\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;
|
export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"generic.close": "Close",
|
"generic.close": "Close",
|
||||||
"generic.confirm": "Confirm",
|
"generic.confirm": "Confirm",
|
||||||
"generic.create": "Create",
|
"generic.create": "Create",
|
||||||
|
"generic.create.workflow": "Create Workflow",
|
||||||
"generic.deleteWorkflowError": "Problem deleting workflow",
|
"generic.deleteWorkflowError": "Problem deleting workflow",
|
||||||
"generic.filtersApplied": "Filters are currently applied.",
|
"generic.filtersApplied": "Filters are currently applied.",
|
||||||
"generic.field": "field",
|
"generic.field": "field",
|
||||||
@@ -92,6 +93,7 @@
|
|||||||
"generic.viewDocs": "View docs",
|
"generic.viewDocs": "View docs",
|
||||||
"generic.workflows": "Workflows",
|
"generic.workflows": "Workflows",
|
||||||
"generic.rename": "Rename",
|
"generic.rename": "Rename",
|
||||||
|
"generic.missing.permissions": "Missing permissions to perform this action",
|
||||||
"about.aboutN8n": "About n8n",
|
"about.aboutN8n": "About n8n",
|
||||||
"about.close": "Close",
|
"about.close": "Close",
|
||||||
"about.license": "License",
|
"about.license": "License",
|
||||||
@@ -900,15 +902,21 @@
|
|||||||
"forms.resourceFiltersDropdown.owner": "Owner",
|
"forms.resourceFiltersDropdown.owner": "Owner",
|
||||||
"forms.resourceFiltersDropdown.owner.placeholder": "Filter by owner",
|
"forms.resourceFiltersDropdown.owner.placeholder": "Filter by owner",
|
||||||
"forms.resourceFiltersDropdown.reset": "Reset all",
|
"forms.resourceFiltersDropdown.reset": "Reset all",
|
||||||
"folders.actions.create": "Create folder",
|
"folders.actions.create": "Create folder inside",
|
||||||
"folders.actions.create.workflow": "Create workflow",
|
"folders.actions.create.workflow": "Create workflow inside",
|
||||||
"folders.actions.moveToFolder": "Move to folder",
|
"folders.actions.moveToFolder": "Move to folder",
|
||||||
"folders.add": "Add folder",
|
"folders.add": "Add folder",
|
||||||
"folders.add.here.message": "Create a new folder here",
|
"folders.add.here.message": "Create a new folder here",
|
||||||
"folders.add.to.parent.message": "Create folder in \"{parent}\"",
|
"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.title": "Folder created",
|
||||||
"folders.add.success.message": "Created new folder \"{folderName}\"<br><a href=\"{link}\">Open folder</a>",
|
"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.confirm.title": "Delete \"{folderName}\"",
|
||||||
"folders.delete.typeToConfirm": "delete {folderName}",
|
"folders.delete.typeToConfirm": "delete {folderName}",
|
||||||
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
|
"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.error.message": "Problem while deleting folder",
|
||||||
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
|
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
|
||||||
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
|
"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.selectFolder": "Folder to transfer to",
|
||||||
"folders.transfer.select.placeholder": "Select folder",
|
"folders.transfer.select.placeholder": "Select folder",
|
||||||
"folders.rename.message": "Rename \"{folderName}\"",
|
"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.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.select.placeholder": "Search for a folder",
|
||||||
"folders.move.modal.confirm": "Move to 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.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.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.error.title": "Problem moving folder",
|
||||||
"folders.move.workflow.error.title": "Problem moving workflow",
|
"folders.move.workflow.error.title": "Problem moving workflow",
|
||||||
"folders.move.workflow.success.title": "Successfully moved 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.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.open.error.title": "Problem opening folder",
|
||||||
"folders.create.error.title": "Problem creating 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.oauth1Api": "OAuth1 API",
|
||||||
"generic.oauth2Api": "OAuth2 API",
|
"generic.oauth2Api": "OAuth2 API",
|
||||||
"genericHelpers.loading": "Loading",
|
"genericHelpers.loading": "Loading",
|
||||||
@@ -2465,6 +2479,7 @@
|
|||||||
"workflows.create.personal.toast.title": "Workflow successfully created",
|
"workflows.create.personal.toast.title": "Workflow successfully created",
|
||||||
"workflows.create.personal.toast.text": "This workflow has been created inside your personal space.",
|
"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.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.",
|
"workflows.create.project.toast.text": "All members from {projectName} will have access to this workflow.",
|
||||||
"workflowSelectorParameterInput.createNewSubworkflow.name": "My Sub-Workflow",
|
"workflowSelectorParameterInput.createNewSubworkflow.name": "My Sub-Workflow",
|
||||||
"importCurlModal.title": "Import cURL command",
|
"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.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.title": "Execution search and tagging",
|
||||||
"communityPlusModal.features.third.description": "Search and organize past workflow executions for easier review",
|
"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.input.email.label": "Enter email to receive your license key",
|
||||||
"communityPlusModal.button.skip": "Skip",
|
"communityPlusModal.button.skip": "Skip",
|
||||||
"communityPlusModal.button.confirm": "Send me a free license key",
|
"communityPlusModal.button.confirm": "Send me a free license key",
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { STORES } from '@/constants';
|
import { STORES } from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
|
ChangeLocationSearchResult,
|
||||||
FolderCreateResponse,
|
FolderCreateResponse,
|
||||||
FolderListItem,
|
|
||||||
FolderShortInfo,
|
FolderShortInfo,
|
||||||
FolderTreeResponseItem,
|
FolderTreeResponseItem,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import * as workflowsApi from '@/api/workflows';
|
import * as workflowsApi from '@/api/workflows';
|
||||||
import { useRootStore } from './root.store';
|
import { useRootStore } from './root.store';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
|
||||||
export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||||
const rootStore = useRootStore();
|
const rootStore = useRootStore();
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
const totalWorkflowCount = ref<number>(0);
|
const totalWorkflowCount = ref<number>(0);
|
||||||
|
|
||||||
@@ -119,7 +121,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
filter?: {
|
filter?: {
|
||||||
name?: string;
|
name?: string;
|
||||||
},
|
},
|
||||||
): Promise<FolderListItem[]> {
|
): Promise<ChangeLocationSearchResult[]> {
|
||||||
const folders = await workflowsApi.getProjectFolders(
|
const folders = await workflowsApi.getProjectFolders(
|
||||||
rootStore.restApiContext,
|
rootStore.restApiContext,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -146,6 +148,14 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
parentFolderId?: string,
|
parentFolderId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await workflowsApi.moveFolder(rootStore.restApiContext, projectId, folderId, parentFolderId);
|
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(
|
async function fetchFolderContent(
|
||||||
@@ -155,6 +165,78 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
return await workflowsApi.getFolderContent(rootStore.restApiContext, projectId, folderId);
|
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 {
|
return {
|
||||||
fetchTotalWorkflowsAndFoldersCount,
|
fetchTotalWorkflowsAndFoldersCount,
|
||||||
breadcrumbsCache,
|
breadcrumbsCache,
|
||||||
@@ -170,5 +252,6 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
fetchFoldersAvailableForMove,
|
fetchFoldersAvailableForMove,
|
||||||
moveFolder,
|
moveFolder,
|
||||||
fetchFolderContent,
|
fetchFolderContent,
|
||||||
|
getHiddenBreadcrumbsItems,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
SETUP_CREDENTIALS_MODAL_KEY,
|
SETUP_CREDENTIALS_MODAL_KEY,
|
||||||
PROJECT_MOVE_RESOURCE_MODAL,
|
PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
NEW_ASSISTANT_SESSION_MODAL,
|
NEW_ASSISTANT_SESSION_MODAL,
|
||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
|
||||||
].map((modalKey) => [modalKey, { open: false }]),
|
].map((modalKey) => [modalKey, { open: false }]),
|
||||||
),
|
),
|
||||||
[DELETE_USER_MODAL_KEY]: {
|
[DELETE_USER_MODAL_KEY]: {
|
||||||
@@ -177,6 +176,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
workflowListEventBus: undefined,
|
workflowListEventBus: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[COMMUNITY_PLUS_ENROLLMENT_MODAL]: {
|
||||||
|
open: false,
|
||||||
|
data: {
|
||||||
|
customHeading: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const modalStack = ref<string[]>([]);
|
const modalStack = ref<string[]>([]);
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ describe('SettingsUsageAndPlan', () => {
|
|||||||
expect(getByRole('button', { name: 'Unlock' })).toBeVisible();
|
expect(getByRole('button', { name: 'Unlock' })).toBeVisible();
|
||||||
|
|
||||||
await userEvent.click(getByRole('button', { name: 'Unlock' }));
|
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 () => {
|
it('should show community registered badge', async () => {
|
||||||
|
|||||||
@@ -148,7 +148,12 @@ const onDialogOpened = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openCommunityRegisterModal = () => {
|
const openCommunityRegisterModal = () => {
|
||||||
uiStore.openModal(COMMUNITY_PLUS_ENROLLMENT_MODAL);
|
uiStore.openModalWithData({
|
||||||
|
name: COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
data: {
|
||||||
|
customHeading: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ onMounted(() => {
|
|||||||
<N8nTooltip placement="top" :disabled="canCreateVariables">
|
<N8nTooltip placement="top" :disabled="canCreateVariables">
|
||||||
<div>
|
<div>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
size="large"
|
size="medium"
|
||||||
block
|
block
|
||||||
:disabled="!canCreateVariables"
|
:disabled="!canCreateVariables"
|
||||||
data-test-id="resources-list-add"
|
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 pinia: ReturnType<typeof createTestingPinia>;
|
||||||
let foldersStore: ReturnType<typeof mockedStore<typeof useFoldersStore>>;
|
let foldersStore: ReturnType<typeof mockedStore<typeof useFoldersStore>>;
|
||||||
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
let workflowsStore: ReturnType<typeof mockedStore<typeof useWorkflowsStore>>;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
VIEWS,
|
VIEWS,
|
||||||
DEFAULT_WORKFLOW_PAGE_SIZE,
|
DEFAULT_WORKFLOW_PAGE_SIZE,
|
||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
VALID_FOLDER_NAME_REGEX,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
IUser,
|
IUser,
|
||||||
@@ -59,6 +59,8 @@ import { debounce } from 'lodash-es';
|
|||||||
import { useMessage } from '@/composables/useMessage';
|
import { useMessage } from '@/composables/useMessage';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { useFoldersStore } from '@/stores/folders.store';
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
|
import { useFolders } from '@/composables/useFolders';
|
||||||
|
import { useUsageStore } from '@/stores/usage.store';
|
||||||
|
|
||||||
interface Filters extends BaseFilters {
|
interface Filters extends BaseFilters {
|
||||||
status: string | boolean;
|
status: string | boolean;
|
||||||
@@ -84,6 +86,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const folderHelpers = useFolders();
|
||||||
|
|
||||||
const sourceControlStore = useSourceControlStore();
|
const sourceControlStore = useSourceControlStore();
|
||||||
const usersStore = useUsersStore();
|
const usersStore = useUsersStore();
|
||||||
@@ -95,6 +98,7 @@ const telemetry = useTelemetry();
|
|||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
const tagsStore = useTagsStore();
|
const tagsStore = useTagsStore();
|
||||||
const foldersStore = useFoldersStore();
|
const foldersStore = useFoldersStore();
|
||||||
|
const usageStore = useUsageStore();
|
||||||
|
|
||||||
const documentTitle = useDocumentTitle();
|
const documentTitle = useDocumentTitle();
|
||||||
const { callDebounced } = useDebounce();
|
const { callDebounced } = useDebounce();
|
||||||
@@ -180,8 +184,17 @@ const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
|||||||
const isShareable = computed(
|
const isShareable = computed(
|
||||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const foldersEnabled = computed(() => {
|
||||||
|
return settingsStore.isFoldersFeatureEnabled;
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamProjectsEnabled = computed(() => {
|
||||||
|
return projectsStore.isTeamProjectFeatureEnabled;
|
||||||
|
});
|
||||||
|
|
||||||
const showFolders = computed(() => {
|
const showFolders = computed(() => {
|
||||||
return settingsStore.isFoldersFeatureEnabled && !isOverviewPage.value;
|
return foldersEnabled.value && !isOverviewPage.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentFolder = computed(() => {
|
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
|
* WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS
|
||||||
*/
|
*/
|
||||||
@@ -383,6 +409,7 @@ const initialize = async () => {
|
|||||||
usersStore.fetchUsers(),
|
usersStore.fetchUsers(),
|
||||||
fetchWorkflows(),
|
fetchWorkflows(),
|
||||||
workflowsStore.fetchActiveWorkflows(),
|
workflowsStore.fetchActiveWorkflows(),
|
||||||
|
usageStore.getLicenseInfo(),
|
||||||
]);
|
]);
|
||||||
breadcrumbsLoading.value = false;
|
breadcrumbsLoading.value = false;
|
||||||
workflowsAndFolders.value = resourcesPage;
|
workflowsAndFolders.value = resourcesPage;
|
||||||
@@ -837,11 +864,14 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
|
|||||||
if (!clickedFolder) return;
|
if (!clickedFolder) return;
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case FOLDER_LIST_ITEM_ACTIONS.CREATE:
|
case FOLDER_LIST_ITEM_ACTIONS.CREATE:
|
||||||
await createFolder({
|
await createFolder(
|
||||||
|
{
|
||||||
id: clickedFolder.id,
|
id: clickedFolder.id,
|
||||||
name: clickedFolder.name,
|
name: clickedFolder.name,
|
||||||
type: 'folder',
|
type: 'folder',
|
||||||
});
|
},
|
||||||
|
{ openAfterCreate: true },
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW:
|
case FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW:
|
||||||
currentFolderId.value = clickedFolder.id;
|
currentFolderId.value = clickedFolder.id;
|
||||||
@@ -876,14 +906,16 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
|
|||||||
|
|
||||||
// Reusable action handlers
|
// Reusable action handlers
|
||||||
// Both action handlers ultimately call these methods once folder to apply action to is determined
|
// 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(
|
const promptResponsePromise = message.prompt(
|
||||||
i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }),
|
i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }),
|
||||||
{
|
{
|
||||||
confirmButtonText: i18n.baseText('generic.create'),
|
confirmButtonText: i18n.baseText('generic.create'),
|
||||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||||
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
inputValidator: folderHelpers.validateFolderName,
|
||||||
inputPattern: VALID_FOLDER_NAME_REGEX,
|
|
||||||
customClass: 'add-folder-modal',
|
customClass: 'add-folder-modal',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -917,6 +949,16 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
},
|
},
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
|
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 we are on an empty list, just add the new folder to the list
|
||||||
if (!workflowsAndFolders.value.length) {
|
if (!workflowsAndFolders.value.length) {
|
||||||
workflowsAndFolders.value = [
|
workflowsAndFolders.value = [
|
||||||
@@ -939,9 +981,7 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
// Else fetch again with same filters & pagination applied
|
// Else fetch again with same filters & pagination applied
|
||||||
await fetchWorkflows();
|
await fetchWorkflows();
|
||||||
}
|
}
|
||||||
telemetry.track('User created folder', {
|
}
|
||||||
folder_id: newFolder.id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('folders.create.error.title'));
|
toast.showError(error, i18n.baseText('folders.create.error.title'));
|
||||||
}
|
}
|
||||||
@@ -956,10 +996,9 @@ const renameFolder = async (folderId: string) => {
|
|||||||
{
|
{
|
||||||
confirmButtonText: i18n.baseText('generic.rename'),
|
confirmButtonText: i18n.baseText('generic.rename'),
|
||||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||||
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
|
||||||
inputValue: folder.name,
|
inputValue: folder.name,
|
||||||
inputPattern: VALID_FOLDER_NAME_REGEX,
|
|
||||||
customClass: 'rename-folder-modal',
|
customClass: 'rename-folder-modal',
|
||||||
|
inputValidator: folderHelpers.validateFolderName,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const promptResponse = await promptResponsePromise;
|
const promptResponse = await promptResponsePromise;
|
||||||
@@ -985,6 +1024,14 @@ const renameFolder = async (folderId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createFolderInCurrent = async () => {
|
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;
|
if (!route.params.projectId) return;
|
||||||
const currentParent = currentFolder.value?.name || projectName.value;
|
const currentParent = currentFolder.value?.name || projectName.value;
|
||||||
if (!currentParent) return;
|
if (!currentParent) return;
|
||||||
@@ -1013,19 +1060,22 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
|
|||||||
|
|
||||||
const moveFolder = async (payload: {
|
const moveFolder = async (payload: {
|
||||||
folder: { id: string; name: string };
|
folder: { id: string; name: string };
|
||||||
newParent: { id: string; name: string };
|
newParent: { id: string; name: string; type: 'folder' | 'project' };
|
||||||
}) => {
|
}) => {
|
||||||
if (!route.params.projectId) return;
|
if (!route.params.projectId) return;
|
||||||
try {
|
try {
|
||||||
await foldersStore.moveFolder(
|
await foldersStore.moveFolder(
|
||||||
route.params.projectId as string,
|
route.params.projectId as string,
|
||||||
payload.folder.id,
|
payload.folder.id,
|
||||||
payload.newParent.id,
|
payload.newParent.type === 'project' ? '0' : payload.newParent.id,
|
||||||
);
|
);
|
||||||
const isCurrentFolder = currentFolderId.value === payload.folder.id;
|
const isCurrentFolder = currentFolderId.value === payload.folder.id;
|
||||||
const newFolderURL = router.resolve({
|
const newFolderURL = router.resolve({
|
||||||
name: VIEWS.PROJECTS_FOLDERS,
|
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;
|
}).href;
|
||||||
if (isCurrentFolder) {
|
if (isCurrentFolder) {
|
||||||
// If we just moved the current folder, automatically navigate to the new folder
|
// If we just moved the current folder, automatically navigate to the new folder
|
||||||
@@ -1057,6 +1107,13 @@ const moveWorkflowToFolder = async (payload: {
|
|||||||
name: string;
|
name: string;
|
||||||
parentFolderId?: 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(
|
uiStore.openMoveToFolderModal(
|
||||||
'workflow',
|
'workflow',
|
||||||
{ id: payload.id, name: payload.name, parentFolderId: payload.parentFolderId },
|
{ id: payload.id, name: payload.name, parentFolderId: payload.parentFolderId },
|
||||||
@@ -1066,19 +1123,22 @@ const moveWorkflowToFolder = async (payload: {
|
|||||||
|
|
||||||
const onWorkflowMoved = async (payload: {
|
const onWorkflowMoved = async (payload: {
|
||||||
workflow: { id: string; name: string; oldParentId: string };
|
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;
|
if (!route.params.projectId) return;
|
||||||
try {
|
try {
|
||||||
const newFolderURL = router.resolve({
|
const newFolderURL = router.resolve({
|
||||||
name: VIEWS.PROJECTS_FOLDERS,
|
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;
|
}).href;
|
||||||
const workflowResource = workflowsAndFolders.value.find(
|
const workflowResource = workflowsAndFolders.value.find(
|
||||||
(resource): resource is WorkflowListItem => resource.id === payload.workflow.id,
|
(resource): resource is WorkflowListItem => resource.id === payload.workflow.id,
|
||||||
);
|
);
|
||||||
await workflowsStore.updateWorkflow(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,
|
versionId: workflowResource?.versionId,
|
||||||
});
|
});
|
||||||
await fetchWorkflows();
|
await fetchWorkflows();
|
||||||
@@ -1104,6 +1164,16 @@ const onWorkflowMoved = async (payload: {
|
|||||||
toast.showError(error, i18n.baseText('folders.move.workflow.error.title'));
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -1132,9 +1202,21 @@ const onWorkflowMoved = async (payload: {
|
|||||||
<template #header>
|
<template #header>
|
||||||
<ProjectHeader @create-folder="createFolderInCurrent" />
|
<ProjectHeader @create-folder="createFolderInCurrent" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="showFolders" #add-button>
|
<template v-if="foldersEnabled" #add-button>
|
||||||
<N8nTooltip placement="top" :disabled="readOnlyEnv || !hasPermissionToCreateFolders">
|
<N8nTooltip
|
||||||
|
placement="top"
|
||||||
|
:disabled="!(isOverviewPage || (!readOnlyEnv && hasPermissionToCreateFolders))"
|
||||||
|
>
|
||||||
<template #content>
|
<template #content>
|
||||||
|
<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
|
currentParentName
|
||||||
? i18n.baseText('folders.add.to.parent.message', {
|
? i18n.baseText('folders.add.to.parent.message', {
|
||||||
@@ -1142,9 +1224,10 @@ const onWorkflowMoved = async (payload: {
|
|||||||
})
|
})
|
||||||
: i18n.baseText('folders.add.here.message')
|
: i18n.baseText('folders.add.here.message')
|
||||||
}}
|
}}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<N8nButton
|
<N8nButton
|
||||||
size="large"
|
size="small"
|
||||||
icon="folder-plus"
|
icon="folder-plus"
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
data-test-id="add-folder-button"
|
data-test-id="add-folder-button"
|
||||||
@@ -1211,7 +1294,7 @@ const onWorkflowMoved = async (payload: {
|
|||||||
/>
|
/>
|
||||||
<WorkflowCard
|
<WorkflowCard
|
||||||
v-else
|
v-else
|
||||||
data-test-id="resources-list-item"
|
data-test-id="resources-list-item-workflow"
|
||||||
class="mb-2xs"
|
class="mb-2xs"
|
||||||
:data="data as WorkflowResource"
|
:data="data as WorkflowResource"
|
||||||
:workflow-list-event-bus="workflowListEventBus"
|
:workflow-list-event-bus="workflowListEventBus"
|
||||||
@@ -1225,7 +1308,7 @@ const onWorkflowMoved = async (payload: {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<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">
|
<N8nHeading tag="h2" size="xlarge" class="mb-2xs">
|
||||||
{{
|
{{
|
||||||
currentUser.firstName
|
currentUser.firstName
|
||||||
@@ -1308,6 +1391,34 @@ const onWorkflowMoved = async (payload: {
|
|||||||
</N8nSelect>
|
</N8nSelect>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</ResourcesListLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -1358,7 +1469,8 @@ const onWorkflowMoved = async (payload: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-folder-button {
|
.add-folder-button {
|
||||||
width: 40px;
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumbs-container {
|
.breadcrumbs-container {
|
||||||
@@ -1374,6 +1486,12 @@ const onWorkflowMoved = async (payload: {
|
|||||||
width: 400px;
|
width: 400px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.empty-folder-container {
|
||||||
|
button {
|
||||||
|
margin-top: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
Reference in New Issue
Block a user