mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Add functionality to delete and rename folders (no-changelog) (#13785)
This commit is contained in:
committed by
GitHub
parent
09ebc3adc7
commit
e73f618851
1
.github/workflows/e2e-reusable.yml
vendored
1
.github/workflows/e2e-reusable.yml
vendored
@@ -161,6 +161,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_OPTIONS: --dns-result-order=ipv4first
|
NODE_OPTIONS: --dns-result-order=ipv4first
|
||||||
CYPRESS_NODE_VIEW_VERSION: 2
|
CYPRESS_NODE_VIEW_VERSION: 2
|
||||||
|
N8N_FOLDERS_ENABLED: true
|
||||||
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
E2E_TESTS: true
|
E2E_TESTS: true
|
||||||
|
|||||||
242
cypress/composables/folders.ts
Normal file
242
cypress/composables/folders.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { successToast } from '../pages/notifications';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Getters
|
||||||
|
*/
|
||||||
|
export function getPersonalProjectMenuItem() {
|
||||||
|
return cy.getByTestId('project-personal-menu-item');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOverviewMenuItem() {
|
||||||
|
return cy.getByTestId('menu-item').contains('Overview');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAddResourceDropdown() {
|
||||||
|
return cy.getByTestId('add-resource');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCards() {
|
||||||
|
return cy.getByTestId('folder-card');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCard(name: string) {
|
||||||
|
return cy.getByTestId('folder-card-name').contains(name).closest('[data-test-id="folder-card"]');
|
||||||
|
}
|
||||||
|
export function getAddFolderButton() {
|
||||||
|
return cy.getByTestId('add-folder-button');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListBreadcrumbs() {
|
||||||
|
return cy.getByTestId('main-breadcrumbs');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHomeProjectBreadcrumb() {
|
||||||
|
return getListBreadcrumbs().findChildByTestId('home-project');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getVisibleListBreadcrumbs() {
|
||||||
|
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentBreadcrumb() {
|
||||||
|
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item-current');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainBreadcrumbsEllipsis() {
|
||||||
|
return getListBreadcrumbs().findChildByTestId('hidden-items-menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMainBreadcrumbsEllipsisMenuItems() {
|
||||||
|
return cy
|
||||||
|
.getByTestId('hidden-items-menu')
|
||||||
|
.find('span[aria-controls]')
|
||||||
|
.invoke('attr', 'aria-controls')
|
||||||
|
.then((popperId) => {
|
||||||
|
return cy.get(`#${popperId}`).find('li');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCardBreadCrumbs(folderName: string) {
|
||||||
|
return getFolderCard(folderName).find('[data-test-id="folder-card-breadcrumbs"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCardBreadCrumbsEllipsis(folderName: string) {
|
||||||
|
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="ellipsis"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCardHomeProjectBreadcrumb(folderName: string) {
|
||||||
|
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="folder-card-home-project"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCardCurrentBreadcrumb(folderName: string) {
|
||||||
|
return getFolderCardBreadCrumbs(folderName).find('[data-test-id="breadcrumbs-item-current"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpenHiddenItemsTooltip() {
|
||||||
|
return cy.getByTestId('hidden-items-tooltip').filter(':visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListActionsToggle() {
|
||||||
|
return cy.getByTestId('folder-breadcrumbs-actions');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getListActionItem(name: string) {
|
||||||
|
return cy
|
||||||
|
.getByTestId('folder-breadcrumbs-actions')
|
||||||
|
.find('span[aria-controls]')
|
||||||
|
.invoke('attr', 'aria-controls')
|
||||||
|
.then((popperId) => {
|
||||||
|
return cy.get(`#${popperId}`).find(`[data-test-id="action-${name}"]`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCardActionToggle(folderName: string) {
|
||||||
|
return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderCardActionItem(name: string) {
|
||||||
|
return cy
|
||||||
|
.getByTestId('folder-card-actions')
|
||||||
|
.find('span[aria-controls]')
|
||||||
|
.invoke('attr', 'aria-controls')
|
||||||
|
.then((popperId) => {
|
||||||
|
return cy.get(`#${popperId}`).find(`[data-test-id="action-${name}"]`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFolderDeleteModal() {
|
||||||
|
return cy.getByTestId('deleteFolder-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeleteRadioButton() {
|
||||||
|
return cy.getByTestId('delete-content-radio');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfirmDeleteInput() {
|
||||||
|
return getFolderDeleteModal().findChildByTestId('delete-data-input').find('input');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDeleteFolderModalConfirmButton() {
|
||||||
|
return getFolderDeleteModal().findChildByTestId('confirm-delete-folder-button');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Actions
|
||||||
|
*/
|
||||||
|
export function goToPersonalProject() {
|
||||||
|
getPersonalProjectMenuItem().click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFolderInsideFolder(childName: string, parentName: string) {
|
||||||
|
getFolderCard(parentName).click();
|
||||||
|
createFolderFromListHeaderButton(childName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFolderFromListHeaderButton(folderName: string) {
|
||||||
|
getAddFolderButton().click();
|
||||||
|
createNewFolder(folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFolderFromProjectHeader(folderName: string) {
|
||||||
|
getPersonalProjectMenuItem().click();
|
||||||
|
getAddResourceDropdown().click();
|
||||||
|
cy.getByTestId('action-folder').click();
|
||||||
|
createNewFolder(folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFolderFromListDropdown(folderName: string) {
|
||||||
|
getListActionsToggle().click();
|
||||||
|
getListActionItem('create').click();
|
||||||
|
createNewFolder(folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFolderFromCardActions(parentName: string, folderName: string) {
|
||||||
|
getFolderCardActionToggle(parentName).click();
|
||||||
|
getFolderCardActionItem('create').click();
|
||||||
|
createNewFolder(folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameFolderFromListActions(folderName: string, newName: string) {
|
||||||
|
getFolderCard(folderName).click();
|
||||||
|
getListActionsToggle().click();
|
||||||
|
getListActionItem('rename').click();
|
||||||
|
renameFolder(newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renameFolderFromCardActions(folderName: string, newName: string) {
|
||||||
|
getFolderCardActionToggle(folderName).click();
|
||||||
|
getFolderCardActionItem('rename').click();
|
||||||
|
renameFolder(newName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEmptyFolderFromCardDropdown(folderName: string) {
|
||||||
|
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
|
||||||
|
getFolderCard(folderName).click();
|
||||||
|
getListActionsToggle().click();
|
||||||
|
getListActionItem('delete').click();
|
||||||
|
cy.wait('@deleteFolder');
|
||||||
|
successToast().should('contain.text', 'Folder deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEmptyFolderFromListDropdown(folderName: string) {
|
||||||
|
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
|
||||||
|
getFolderCard(folderName).click();
|
||||||
|
getListActionsToggle().click();
|
||||||
|
getListActionItem('delete').click();
|
||||||
|
cy.wait('@deleteFolder');
|
||||||
|
successToast().should('contain.text', 'Folder deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFolderWithContentsFromListDropdown(folderName: string) {
|
||||||
|
getListActionsToggle().click();
|
||||||
|
getListActionItem('delete').click();
|
||||||
|
confirmFolderDelete(folderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFolderWithContentsFromCardDropdown(folderName: string) {
|
||||||
|
getFolderCardActionToggle(folderName).click();
|
||||||
|
getFolderCardActionItem('delete').click();
|
||||||
|
confirmFolderDelete(folderName);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Utils
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types folder name in the prompt and waits for the folder to be created
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
function createNewFolder(name: string) {
|
||||||
|
cy.intercept('POST', '/rest/projects/**').as('createFolder');
|
||||||
|
cy.get('[role=dialog]')
|
||||||
|
.filter(':visible')
|
||||||
|
.within(() => {
|
||||||
|
cy.get('input.el-input__inner').type(name, { delay: 50 });
|
||||||
|
cy.get('button.btn--confirm').click();
|
||||||
|
});
|
||||||
|
cy.wait('@createFolder');
|
||||||
|
successToast().should('exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renameFolder(newName: string) {
|
||||||
|
cy.intercept('PATCH', '/rest/projects/**').as('renameFolder');
|
||||||
|
cy.get('[role=dialog]')
|
||||||
|
.filter(':visible')
|
||||||
|
.within(() => {
|
||||||
|
cy.get('input.el-input__inner').type('{selectall}');
|
||||||
|
cy.get('input.el-input__inner').type(newName, { delay: 50 });
|
||||||
|
cy.get('button.btn--confirm').click();
|
||||||
|
});
|
||||||
|
cy.wait('@renameFolder');
|
||||||
|
successToast().should('exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmFolderDelete(folderName: string) {
|
||||||
|
cy.intercept('DELETE', '/rest/projects/**').as('deleteFolder');
|
||||||
|
getFolderDeleteModal().should('be.visible');
|
||||||
|
getDeleteRadioButton().click();
|
||||||
|
getConfirmDeleteInput().should('be.visible');
|
||||||
|
getConfirmDeleteInput().type(`delete ${folderName}`, { delay: 50 });
|
||||||
|
getDeleteFolderModalConfirmButton().should('be.enabled').click();
|
||||||
|
cy.wait('@deleteFolder');
|
||||||
|
successToast().contains('Folder deleted').should('exist');
|
||||||
|
}
|
||||||
256
cypress/e2e/49-folders.cy.ts
Normal file
256
cypress/e2e/49-folders.cy.ts
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import {
|
||||||
|
createFolderFromCardActions,
|
||||||
|
createFolderFromListDropdown,
|
||||||
|
createFolderFromListHeaderButton,
|
||||||
|
createFolderFromProjectHeader,
|
||||||
|
createFolderInsideFolder,
|
||||||
|
deleteEmptyFolderFromCardDropdown,
|
||||||
|
deleteEmptyFolderFromListDropdown,
|
||||||
|
deleteFolderWithContentsFromCardDropdown,
|
||||||
|
deleteFolderWithContentsFromListDropdown,
|
||||||
|
getAddResourceDropdown,
|
||||||
|
getCurrentBreadcrumb,
|
||||||
|
getFolderCard,
|
||||||
|
getFolderCardActionItem,
|
||||||
|
getFolderCardActionToggle,
|
||||||
|
getFolderCardBreadCrumbsEllipsis,
|
||||||
|
getFolderCardCurrentBreadcrumb,
|
||||||
|
getFolderCardHomeProjectBreadcrumb,
|
||||||
|
getFolderCards,
|
||||||
|
getHomeProjectBreadcrumb,
|
||||||
|
getListBreadcrumbs,
|
||||||
|
getMainBreadcrumbsEllipsis,
|
||||||
|
getMainBreadcrumbsEllipsisMenuItems,
|
||||||
|
getOpenHiddenItemsTooltip,
|
||||||
|
getOverviewMenuItem,
|
||||||
|
getPersonalProjectMenuItem,
|
||||||
|
getVisibleListBreadcrumbs,
|
||||||
|
goToPersonalProject,
|
||||||
|
renameFolderFromCardActions,
|
||||||
|
renameFolderFromListActions,
|
||||||
|
} from '../composables/folders';
|
||||||
|
import { visitWorkflowsPage } from '../composables/workflowsPage';
|
||||||
|
import { successToast } from '../pages/notifications';
|
||||||
|
|
||||||
|
describe('Folders', () => {
|
||||||
|
before(() => {
|
||||||
|
cy.resetDatabase();
|
||||||
|
cy.enableFeature('sharing');
|
||||||
|
cy.enableFeature('advancedPermissions');
|
||||||
|
cy.enableFeature('projectRole:admin');
|
||||||
|
cy.enableFeature('projectRole:editor');
|
||||||
|
cy.changeQuota('maxTeamProjects', -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
visitWorkflowsPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Create and navigate folders', () => {
|
||||||
|
it('should create folder from the project header', () => {
|
||||||
|
createFolderFromProjectHeader('My Folder');
|
||||||
|
getFolderCards().should('have.length.greaterThan', 0);
|
||||||
|
// Clicking on the success toast should navigate to the folder
|
||||||
|
successToast().find('a').click();
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'My Folder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create folder from the list header button', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
// First create a folder so list appears
|
||||||
|
createFolderFromProjectHeader('Test 2');
|
||||||
|
createFolderFromListHeaderButton('My Folder 2');
|
||||||
|
getFolderCards().should('have.length.greaterThan', 0);
|
||||||
|
// Clicking on the success toast should navigate to the folder
|
||||||
|
successToast().find('a').contains('My Folder 2').click();
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'My Folder 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create folder from the list header dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Created from list dropdown');
|
||||||
|
getFolderCard('Created from list dropdown').should('exist');
|
||||||
|
getFolderCard('Created from list dropdown').click();
|
||||||
|
createFolderFromListDropdown('Child Folder');
|
||||||
|
successToast().should('exist');
|
||||||
|
getFolderCard('Child Folder').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create folder from the card dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Created from card dropdown');
|
||||||
|
getFolderCard('Created from card dropdown').should('exist');
|
||||||
|
createFolderFromCardActions('Created from card dropdown', 'Child Folder');
|
||||||
|
successToast().should('exist');
|
||||||
|
// Open parent folder to see the new child folder
|
||||||
|
getFolderCard('Created from card dropdown').click();
|
||||||
|
getFolderCard('Child Folder').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate folders using breadcrumbs and dropdown menu', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Navigate Test');
|
||||||
|
// Open folder using menu item
|
||||||
|
getFolderCardActionToggle('Navigate Test').click();
|
||||||
|
getFolderCardActionItem('open').click();
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Navigate Test');
|
||||||
|
// Create new child folder and navigate to it
|
||||||
|
createFolderFromListHeaderButton('Child Folder');
|
||||||
|
getFolderCard('Child Folder').should('exist');
|
||||||
|
getFolderCard('Child Folder').click();
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Child Folder');
|
||||||
|
// Navigate back to parent folder using breadcrumbs
|
||||||
|
getVisibleListBreadcrumbs().contains('Navigate Test').click();
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Navigate Test');
|
||||||
|
// Go back to home project using breadcrumbs
|
||||||
|
getHomeProjectBreadcrumb().click();
|
||||||
|
getListBreadcrumbs().should('not.exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Creates folders inside folders and also checks breadcrumbs
|
||||||
|
it('should create multiple levels of folders', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Multi-level Test');
|
||||||
|
createFolderInsideFolder('Child Folder', 'Multi-level Test');
|
||||||
|
// One level deep:
|
||||||
|
// - Both main breadcrumbs & card breadcrumbs should only show home project and current folder
|
||||||
|
getHomeProjectBreadcrumb().should('exist');
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Multi-level Test');
|
||||||
|
getFolderCard('Child Folder').should('exist');
|
||||||
|
getFolderCardHomeProjectBreadcrumb('Child Folder').should('exist');
|
||||||
|
getFolderCardCurrentBreadcrumb('Child Folder').should('contain.text', 'Multi-level Test');
|
||||||
|
// No hidden items at this level
|
||||||
|
getFolderCardBreadCrumbsEllipsis('Child Folder').should('not.exist');
|
||||||
|
|
||||||
|
createFolderInsideFolder('Child Folder 2', 'Child Folder');
|
||||||
|
// Two levels deep:
|
||||||
|
// - Main breadcrumbs should also show parent folder, without hidden ellipsis
|
||||||
|
// - Card breadcrumbs should show home project, parent folder, with hidden ellipsis
|
||||||
|
getHomeProjectBreadcrumb().should('exist');
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Child Folder');
|
||||||
|
getVisibleListBreadcrumbs().should('have.length', 1);
|
||||||
|
getMainBreadcrumbsEllipsis().should('not.exist');
|
||||||
|
getFolderCardCurrentBreadcrumb('Child Folder 2').should('contain.text', 'Child Folder');
|
||||||
|
getFolderCardBreadCrumbsEllipsis('Child Folder 2').should('exist');
|
||||||
|
|
||||||
|
// Three levels deep:
|
||||||
|
// - Main breadcrumbs should show parents up to the grandparent folder, with one hidden element
|
||||||
|
// - Card breadcrumbs should now show two hidden elements
|
||||||
|
createFolderInsideFolder('Child Folder 3', 'Child Folder 2');
|
||||||
|
getVisibleListBreadcrumbs().should('have.length', 1);
|
||||||
|
getMainBreadcrumbsEllipsis().should('exist');
|
||||||
|
// Clicking on the ellipsis should show hidden element in main breadcrumbs
|
||||||
|
getMainBreadcrumbsEllipsis().click();
|
||||||
|
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Multi-level Test');
|
||||||
|
getMainBreadcrumbsEllipsis().click();
|
||||||
|
// Card breadcrumbs should show two hidden elements
|
||||||
|
getFolderCardBreadCrumbsEllipsis('Child Folder 3').should('exist');
|
||||||
|
// Clicking on the ellipsis should show hidden element in card breadcrumbs
|
||||||
|
getFolderCardBreadCrumbsEllipsis('Child Folder 3').click();
|
||||||
|
getOpenHiddenItemsTooltip().should('be.visible');
|
||||||
|
getOpenHiddenItemsTooltip().should('contain.text', 'Multi-level Test / Child Folder');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Make sure breadcrumbs and folder card show correct info when landing straight on a folder page
|
||||||
|
it('should correctly render all elements when landing on a folder page', () => {
|
||||||
|
// Create a few levels of folders
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Landing Test');
|
||||||
|
createFolderInsideFolder('Child Folder', 'Landing Test');
|
||||||
|
createFolderInsideFolder('Child Folder 2', 'Child Folder');
|
||||||
|
createFolderInsideFolder('Child Folder 3', 'Child Folder 2');
|
||||||
|
// Reload page to simulate landing on a folder page
|
||||||
|
cy.reload();
|
||||||
|
// Main list breadcrumbs should show home project, parent, grandparent, with one hidden element
|
||||||
|
getHomeProjectBreadcrumb().should('exist');
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Child Folder 2');
|
||||||
|
getVisibleListBreadcrumbs().should('have.length', 1);
|
||||||
|
getVisibleListBreadcrumbs().first().should('contain.text', 'Child Folder');
|
||||||
|
getMainBreadcrumbsEllipsis().should('exist');
|
||||||
|
getMainBreadcrumbsEllipsis().click();
|
||||||
|
getMainBreadcrumbsEllipsisMenuItems().first().should('contain.text', 'Landing Test');
|
||||||
|
// Should load child folder card
|
||||||
|
getFolderCard('Child Folder 3').should('exist');
|
||||||
|
// Card breadcrumbs should show home project and parent, with two hidden elements
|
||||||
|
getFolderCardHomeProjectBreadcrumb('Child Folder 3').should('exist');
|
||||||
|
getFolderCardCurrentBreadcrumb('Child Folder 3').should('contain.text', 'Child Folder 2');
|
||||||
|
getFolderCardBreadCrumbsEllipsis('Child Folder 3').should('exist');
|
||||||
|
getFolderCardBreadCrumbsEllipsis('Child Folder 3').click();
|
||||||
|
getOpenHiddenItemsTooltip().should('be.visible');
|
||||||
|
getOpenHiddenItemsTooltip().should('contain.text', 'Landing Test / Child Folder');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show folders only in projects', () => {
|
||||||
|
// No folder cards should be shown in the overview page
|
||||||
|
getOverviewMenuItem().click();
|
||||||
|
getFolderCards().should('not.exist');
|
||||||
|
// Option to create folders should not be available in the dropdown
|
||||||
|
getAddResourceDropdown().click();
|
||||||
|
cy.getByTestId('action-folder').should('not.exist');
|
||||||
|
|
||||||
|
// In personal, we should see previously created folders
|
||||||
|
getPersonalProjectMenuItem().click();
|
||||||
|
cy.getByTestId('action-folder').should('exist');
|
||||||
|
createFolderFromProjectHeader('Personal Folder');
|
||||||
|
getFolderCards().should('exist');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rename and delete folders', () => {
|
||||||
|
it('should rename folder from main dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Rename Me');
|
||||||
|
getFolderCard('Rename Me').should('exist');
|
||||||
|
renameFolderFromListActions('Rename Me', 'Renamed');
|
||||||
|
getCurrentBreadcrumb().should('contain.text', 'Renamed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should rename folder from card dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Rename Me 2');
|
||||||
|
renameFolderFromCardActions('Rename Me 2', 'Renamed 2');
|
||||||
|
getFolderCard('Renamed 2').should('exist');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete empty folder from card dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Delete Me');
|
||||||
|
getFolderCard('Delete Me').should('exist');
|
||||||
|
deleteEmptyFolderFromCardDropdown('Delete Me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete empty folder from main dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('Delete Me 2');
|
||||||
|
getFolderCard('Delete Me 2').should('exist');
|
||||||
|
deleteEmptyFolderFromListDropdown('Delete Me 2');
|
||||||
|
// Since we deleted the current folder, we should be back in the home project
|
||||||
|
getListBreadcrumbs().should('not.exist');
|
||||||
|
getPersonalProjectMenuItem().find('li').should('have.class', 'is-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn before deleting non-empty folder from list dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('I have children');
|
||||||
|
createFolderInsideFolder('Child 1', 'I have children');
|
||||||
|
deleteFolderWithContentsFromListDropdown('I have children');
|
||||||
|
// Since we deleted the current folder, we should be back in the home project
|
||||||
|
getListBreadcrumbs().should('not.exist');
|
||||||
|
getPersonalProjectMenuItem().find('li').should('have.class', 'is-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Once we have a backend endpoint that returns sub-folder count, enable this
|
||||||
|
// eslint-disable-next-line n8n-local-rules/no-skipped-tests
|
||||||
|
it.skip('should warn before deleting non-empty folder from card dropdown', () => {
|
||||||
|
goToPersonalProject();
|
||||||
|
createFolderFromProjectHeader('I also have family');
|
||||||
|
createFolderInsideFolder('Child 1', 'I also have family');
|
||||||
|
// Back to home
|
||||||
|
getHomeProjectBreadcrumb().click();
|
||||||
|
getFolderCard('I also have family').should('exist');
|
||||||
|
deleteFolderWithContentsFromCardDropdown('I also have family');
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Once we have backend endpoint that lists project folders, test transfer when deleting
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -47,6 +47,7 @@ switch (scenario) {
|
|||||||
testCommand: 'cypress open',
|
testCommand: 'cypress open',
|
||||||
customEnv: {
|
customEnv: {
|
||||||
CYPRESS_NODE_VIEW_VERSION: 2,
|
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||||
|
N8N_FOLDERS_ENABLED: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -58,6 +59,7 @@ switch (scenario) {
|
|||||||
customEnv: {
|
customEnv: {
|
||||||
CYPRESS_NODE_VIEW_VERSION: 1,
|
CYPRESS_NODE_VIEW_VERSION: 1,
|
||||||
CYPRESS_BASE_URL: 'http://localhost:8080',
|
CYPRESS_BASE_URL: 'http://localhost:8080',
|
||||||
|
N8N_FOLDERS_ENABLED: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -69,6 +71,7 @@ switch (scenario) {
|
|||||||
customEnv: {
|
customEnv: {
|
||||||
CYPRESS_NODE_VIEW_VERSION: 2,
|
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||||
CYPRESS_BASE_URL: 'http://localhost:8080',
|
CYPRESS_BASE_URL: 'http://localhost:8080',
|
||||||
|
N8N_FOLDERS_ENABLED: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@@ -82,6 +85,7 @@ switch (scenario) {
|
|||||||
testCommand: `cypress run --headless ${specParam}`,
|
testCommand: `cypress run --headless ${specParam}`,
|
||||||
customEnv: {
|
customEnv: {
|
||||||
CYPRESS_NODE_VIEW_VERSION: 2,
|
CYPRESS_NODE_VIEW_VERSION: 2,
|
||||||
|
N8N_FOLDERS_ENABLED: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -155,9 +155,11 @@ describe('Breadcrumbs', async () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(getByTestId('ellipsis')).toBeTruthy();
|
expect(getByTestId('ellipsis')).toBeTruthy();
|
||||||
expect(getByTestId('action-toggle')).toBeTruthy();
|
expect(getByTestId('hidden-items-menu')).toBeTruthy();
|
||||||
expect(getByTestId('ellipsis')).toHaveClass('disabled');
|
expect(getByTestId('ellipsis')).toHaveClass('disabled');
|
||||||
expect(getByTestId('action-toggle').querySelector('.el-dropdown')).toHaveClass('is-disabled');
|
expect(getByTestId('hidden-items-menu').querySelector('.el-dropdown')).toHaveClass(
|
||||||
|
'is-disabled',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not highlight last item for "highlightLastItem = false" ', () => {
|
it('does not highlight last item for "highlightLastItem = false" ', () => {
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const handleTooltipClose = () => {
|
|||||||
>
|
>
|
||||||
<slot name="prepend"></slot>
|
<slot name="prepend"></slot>
|
||||||
<ul :class="$style.list">
|
<ul :class="$style.list">
|
||||||
<li v-if="$slots.prepend && items.length" :class="$style.separator" aria-hidden="true">
|
<li v-if="$slots.prepend && items.length" :class="$style.separator">
|
||||||
{{ separator }}
|
{{ separator }}
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
@@ -157,6 +157,7 @@ const handleTooltipClose = () => {
|
|||||||
placement="bottom"
|
placement="bottom"
|
||||||
size="small"
|
size="small"
|
||||||
icon-orientation="horizontal"
|
icon-orientation="horizontal"
|
||||||
|
data-test-id="hidden-items-menu"
|
||||||
@visible-change="onHiddenMenuVisibleChange"
|
@visible-change="onHiddenMenuVisibleChange"
|
||||||
@action="emitItemSelected"
|
@action="emitItemSelected"
|
||||||
>
|
>
|
||||||
@@ -183,7 +184,7 @@ const handleTooltipClose = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else :class="$style.tooltipContent">
|
<div v-else :class="$style.tooltipContent">
|
||||||
<div>
|
<div data-test-id="hidden-items-tooltip">
|
||||||
<n8n-text>{{ loadedHiddenItems.map((item) => item.label).join(' / ') }}</n8n-text>
|
<n8n-text>{{ loadedHiddenItems.map((item) => item.label).join(' / ') }}</n8n-text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,20 +192,22 @@ const handleTooltipClose = () => {
|
|||||||
<span :class="$style['tooltip-ellipsis']">...</span>
|
<span :class="$style['tooltip-ellipsis']">...</span>
|
||||||
</n8n-tooltip>
|
</n8n-tooltip>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="showEllipsis" :class="$style.separator" aria-hidden="true">{{ separator }}</li>
|
<li v-if="showEllipsis" :class="$style.separator">{{ separator }}</li>
|
||||||
<template v-for="(item, index) in items" :key="item.id">
|
<template v-for="(item, index) in items" :key="item.id">
|
||||||
<li
|
<li
|
||||||
:class="{
|
:class="{
|
||||||
[$style.item]: true,
|
[$style.item]: true,
|
||||||
[$style.current]: props.highlightLastItem && index === items.length - 1,
|
[$style.current]: props.highlightLastItem && index === items.length - 1,
|
||||||
}"
|
}"
|
||||||
data-test-id="breadcrumbs-item"
|
:data-test-id="
|
||||||
|
index === items.length - 1 ? 'breadcrumbs-item-current' : 'breadcrumbs-item'
|
||||||
|
"
|
||||||
@click.prevent="emitItemSelected(item.id)"
|
@click.prevent="emitItemSelected(item.id)"
|
||||||
>
|
>
|
||||||
<n8n-link v-if="item.href" :href="item.href" theme="text">{{ item.label }}</n8n-link>
|
<n8n-link v-if="item.href" :href="item.href" theme="text">{{ item.label }}</n8n-link>
|
||||||
<n8n-text v-else>{{ item.label }}</n8n-text>
|
<n8n-text v-else>{{ item.label }}</n8n-text>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="index !== items.length - 1" :class="$style.separator" aria-hidden="true">
|
<li v-if="index !== items.length - 1" :class="$style.separator">
|
||||||
{{ separator }}
|
{{ separator }}
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
@@ -232,6 +235,7 @@ const handleTooltipClose = () => {
|
|||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item.current span {
|
.item.current span {
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = fal
|
|||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
|
<li class="item" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</ul>
|
</ul>
|
||||||
</div>"
|
</div>"
|
||||||
@@ -25,12 +25,12 @@ exports[`Breadcrumbs > renders custom separator correctly 1`] = `
|
|||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">➮</li>
|
<li class="separator">➮</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">➮</li>
|
<li class="separator">➮</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">➮</li>
|
<li class="separator">➮</li>
|
||||||
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
|
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</ul>
|
</ul>
|
||||||
</div>"
|
</div>"
|
||||||
@@ -43,12 +43,12 @@ exports[`Breadcrumbs > renders default version correctly 1`] = `
|
|||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
|
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</ul>
|
</ul>
|
||||||
</div>"
|
</div>"
|
||||||
@@ -58,16 +58,16 @@ exports[`Breadcrumbs > renders slots correctly 1`] = `
|
|||||||
"<div class="container medium">
|
"<div class="container medium">
|
||||||
<div>[PRE] Custom content</div>
|
<div>[PRE] Custom content</div>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
|
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</ul>
|
</ul>
|
||||||
<div>[POST] Custom content</div>
|
<div>[POST] Custom content</div>
|
||||||
@@ -81,12 +81,12 @@ exports[`Breadcrumbs > renders small version correctly 1`] = `
|
|||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder1" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 1</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder2" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 2</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
<li class="item" data-test-id="breadcrumbs-item"><a href="/folder3" target="_blank" class="n8n-link"><span class="text"><span class="n8n-text size-medium regular">Folder 3</span></span></a></li>
|
||||||
<li class="separator" aria-hidden="true">/</li>
|
<li class="separator">/</li>
|
||||||
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
|
<li class="item current" data-test-id="breadcrumbs-item-current"><span class="n8n-text size-medium regular">Current</span></li>
|
||||||
<!--v-if-->
|
<!--v-if-->
|
||||||
</ul>
|
</ul>
|
||||||
</div>"
|
</div>"
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ export type BaseFolderItem = BaseResource & {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
workflowCount: number;
|
workflowCount: number;
|
||||||
|
subFolderCount: number;
|
||||||
parentFolder?: FolderShortInfo;
|
parentFolder?: FolderShortInfo;
|
||||||
homeProject?: ProjectSharingData;
|
homeProject?: ProjectSharingData;
|
||||||
sharedWithProjects?: ProjectSharingData[];
|
sharedWithProjects?: ProjectSharingData[];
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
FolderCreateResponse,
|
FolderCreateResponse,
|
||||||
|
FolderListItem,
|
||||||
FolderTreeResponseItem,
|
FolderTreeResponseItem,
|
||||||
IExecutionResponse,
|
IExecutionResponse,
|
||||||
IExecutionsCurrentSummaryExtended,
|
IExecutionsCurrentSummaryExtended,
|
||||||
@@ -110,3 +111,32 @@ export async function getFolderPath(
|
|||||||
`/projects/${projectId}/folders/${folderId}/tree`,
|
`/projects/${projectId}/folders/${folderId}/tree`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteFolder(
|
||||||
|
context: IRestApiContext,
|
||||||
|
projectId: string,
|
||||||
|
folderId: string,
|
||||||
|
transferToFolderId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return await makeRestApiRequest(context, 'DELETE', `/projects/${projectId}/folders/${folderId}`, {
|
||||||
|
transferToFolderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFolder(
|
||||||
|
context: IRestApiContext,
|
||||||
|
projectId: string,
|
||||||
|
folderId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return await makeRestApiRequest(context, 'PATCH', `/projects/${projectId}/folders/${folderId}`, {
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProjectFolders(
|
||||||
|
context: IRestApiContext,
|
||||||
|
projectId: string,
|
||||||
|
): Promise<FolderListItem[]> {
|
||||||
|
return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/folders`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import Modal from '@/components/Modal.vue';
|
||||||
|
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import type { FolderListItem } from '@/Interface';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modalName: string;
|
||||||
|
activeId: string;
|
||||||
|
data: {
|
||||||
|
workflowListEventBus: EventBus;
|
||||||
|
content: {
|
||||||
|
workflowCount: number;
|
||||||
|
subFolderCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const modalBus = createEventBus();
|
||||||
|
const { showMessage, showError } = useToast();
|
||||||
|
const i18n = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const foldersStore = useFoldersStore();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const operation = ref('');
|
||||||
|
const deleteConfirmText = ref('');
|
||||||
|
const selectedFolderId = ref<string | null>(null);
|
||||||
|
const projectFolders = ref<FolderListItem[]>([]);
|
||||||
|
|
||||||
|
const currentFolder = computed(() => {
|
||||||
|
return projectFolders.value.find((folder) => folder.id === props.activeId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Available folders to transfer are all folders except the current folder, it's parent and its children
|
||||||
|
const availableFolders = computed(() => {
|
||||||
|
return projectFolders.value.filter(
|
||||||
|
(folder) =>
|
||||||
|
folder.id !== props.activeId &&
|
||||||
|
folder.parentFolder?.id !== props.activeId &&
|
||||||
|
folder.id !== currentFolder.value?.parentFolder?.id,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderToDelete = computed(() => {
|
||||||
|
if (!props.activeId) return null;
|
||||||
|
return foldersStore.breadcrumbsCache[props.activeId];
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = computed(() => {
|
||||||
|
return folderToDelete.value ? !folderToDelete.value.name : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const folderName = folderToDelete.value?.name ?? '';
|
||||||
|
return i18n.baseText('folders.delete.confirm.title', { interpolate: { folderName } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabled = computed(() => {
|
||||||
|
if (isPending.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
operation.value === 'delete' &&
|
||||||
|
deleteConfirmText.value ===
|
||||||
|
i18n.baseText('folders.delete.typeToConfirm', {
|
||||||
|
interpolate: { folderName: folderToDelete.value?.name ?? '' },
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (operation.value === 'transfer' && selectedFolderId.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const folderContentWarningMessage = computed(() => {
|
||||||
|
const folderCount = props.data.content.subFolderCount ?? 0;
|
||||||
|
const workflowCount = props.data.content.workflowCount ?? 0;
|
||||||
|
let folderText = '';
|
||||||
|
let workflowText = '';
|
||||||
|
if (folderCount > 0) {
|
||||||
|
folderText = i18n.baseText('folder.count', { interpolate: { count: folderCount } });
|
||||||
|
}
|
||||||
|
if (workflowCount > 0) {
|
||||||
|
workflowText = i18n.baseText('workflow.count', { interpolate: { count: workflowCount } });
|
||||||
|
}
|
||||||
|
if (folderCount > 0 && workflowCount > 0) {
|
||||||
|
folderText += ` ${i18n.baseText('folder.and.workflow.separator')} `;
|
||||||
|
}
|
||||||
|
return i18n.baseText('folder.delete.modal.confirmation', {
|
||||||
|
interpolate: {
|
||||||
|
folders: folderText,
|
||||||
|
workflows: workflowText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!enabled.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
await foldersStore.deleteFolder(
|
||||||
|
route.params.projectId as string,
|
||||||
|
props.activeId,
|
||||||
|
selectedFolderId.value ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = '';
|
||||||
|
if (selectedFolderId.value) {
|
||||||
|
const selectedFolder = availableFolders.value.find(
|
||||||
|
(folder) => folder.id === selectedFolderId.value,
|
||||||
|
);
|
||||||
|
message = i18n.baseText('folders.transfer.confirm.message', {
|
||||||
|
interpolate: { folderName: selectedFolder?.name ?? '' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
showMessage({
|
||||||
|
type: 'success',
|
||||||
|
title: i18n.baseText('folders.delete.success.message'),
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
props.data.workflowListEventBus.emit('folder-deleted', { folderId: props.activeId });
|
||||||
|
modalBus.emit('close');
|
||||||
|
} catch (error) {
|
||||||
|
showError(error, i18n.baseText('folders.delete.error.message'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
projectFolders.value = await foldersStore.fetchProjectFolders(route.params.projectId as string);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:name="modalName"
|
||||||
|
:title="title"
|
||||||
|
:center="true"
|
||||||
|
width="520"
|
||||||
|
:event-bus="modalBus"
|
||||||
|
@enter="onSubmit"
|
||||||
|
>
|
||||||
|
<template #content>
|
||||||
|
<div>
|
||||||
|
<div v-if="isPending">
|
||||||
|
<n8n-text color="text-base">{{
|
||||||
|
i18n.baseText('folders.delete.confirm.message')
|
||||||
|
}}</n8n-text>
|
||||||
|
</div>
|
||||||
|
<div v-else :class="$style.content">
|
||||||
|
<div>
|
||||||
|
<n8n-text color="text-base">{{ folderContentWarningMessage }}</n8n-text>
|
||||||
|
</div>
|
||||||
|
<el-radio
|
||||||
|
v-model="operation"
|
||||||
|
data-test-id="transfer-content-radio"
|
||||||
|
label="transfer"
|
||||||
|
@update:model-value="operation = 'transfer'"
|
||||||
|
>
|
||||||
|
<n8n-text color="text-dark">{{ i18n.baseText('folders.transfer.action') }}</n8n-text>
|
||||||
|
</el-radio>
|
||||||
|
<div v-if="operation === 'transfer'" :class="$style.optionInput">
|
||||||
|
<n8n-text color="text-dark">{{
|
||||||
|
i18n.baseText('folders.transfer.selectFolder')
|
||||||
|
}}</n8n-text>
|
||||||
|
<N8nSelect
|
||||||
|
v-model="selectedFolderId"
|
||||||
|
option-label="name"
|
||||||
|
option-value="id"
|
||||||
|
:placeholder="i18n.baseText('folders.transfer.selectFolder')"
|
||||||
|
>
|
||||||
|
<N8nOption
|
||||||
|
v-for="folder in availableFolders"
|
||||||
|
:key="folder.id"
|
||||||
|
:value="folder.id"
|
||||||
|
:label="folder.name"
|
||||||
|
>
|
||||||
|
<div :class="$style['folder-select-item']">
|
||||||
|
<n8n-icon icon="folder" />
|
||||||
|
<span> {{ folder.name }}</span>
|
||||||
|
</div>
|
||||||
|
</N8nOption>
|
||||||
|
</N8nSelect>
|
||||||
|
</div>
|
||||||
|
<el-radio
|
||||||
|
v-model="operation"
|
||||||
|
data-test-id="delete-content-radio"
|
||||||
|
label="delete"
|
||||||
|
@update:model-value="operation = 'delete'"
|
||||||
|
>
|
||||||
|
<n8n-text color="text-dark">{{ i18n.baseText('folders.delete.action') }}</n8n-text>
|
||||||
|
</el-radio>
|
||||||
|
<div
|
||||||
|
v-if="operation === 'delete'"
|
||||||
|
:class="$style.optionInput"
|
||||||
|
data-test-id="delete-data-input"
|
||||||
|
>
|
||||||
|
<n8n-input-label
|
||||||
|
:label="
|
||||||
|
i18n.baseText('folders.delete.confirmation.message', {
|
||||||
|
interpolate: { folderName: folderToDelete?.name ?? '' },
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<n8n-input
|
||||||
|
v-model="deleteConfirmText"
|
||||||
|
data-test-id="delete-data-input"
|
||||||
|
:placeholder="
|
||||||
|
i18n.baseText('folders.delete.typeToConfirm', {
|
||||||
|
interpolate: { folderName: folderToDelete?.name ?? '' },
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</n8n-input-label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #footer>
|
||||||
|
<n8n-button
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="!enabled"
|
||||||
|
:label="i18n.baseText('generic.delete')"
|
||||||
|
float="right"
|
||||||
|
data-test-id="confirm-delete-folder-button"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.content {
|
||||||
|
padding-bottom: var(--spacing-2xs);
|
||||||
|
> * {
|
||||||
|
margin-bottom: var(--spacing-s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.innerContent {
|
||||||
|
> * {
|
||||||
|
margin-bottom: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.optionInput {
|
||||||
|
padding-left: var(--spacing-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-select-item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -51,11 +51,11 @@ const onAction = (action: string) => {
|
|||||||
:highlight-last-item="false"
|
:highlight-last-item="false"
|
||||||
:path-truncated="breadcrumbs.visibleItems[0].parentFolder"
|
:path-truncated="breadcrumbs.visibleItems[0].parentFolder"
|
||||||
:hidden-items="breadcrumbs.hiddenItems"
|
:hidden-items="breadcrumbs.hiddenItems"
|
||||||
data-test-id="folder-card-breadcrumbs"
|
data-test-id="folder-list-breadcrumbs"
|
||||||
@item-selected="onItemSelect"
|
@item-selected="onItemSelect"
|
||||||
>
|
>
|
||||||
<template v-if="currentProject" #prepend>
|
<template v-if="currentProject" #prepend>
|
||||||
<div :class="$style['home-project']">
|
<div :class="$style['home-project']" data-test-id="home-project">
|
||||||
<n8n-link :to="`/projects/${currentProject.id}`">
|
<n8n-link :to="`/projects/${currentProject.id}`">
|
||||||
<N8nText size="large" color="text-base">{{ projectName }}</N8nText>
|
<N8nText size="large" color="text-base">{{ projectName }}</N8nText>
|
||||||
</n8n-link>
|
</n8n-link>
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ const DEFAULT_FOLDER: FolderResource = {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
resourceType: 'folder',
|
resourceType: 'folder',
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
workflowCount: 0,
|
workflowCount: 2,
|
||||||
|
subFolderCount: 2,
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Project 1',
|
name: 'Project 1',
|
||||||
@@ -51,6 +52,7 @@ const PARENT_FOLDER: FolderResource = {
|
|||||||
resourceType: 'folder',
|
resourceType: 'folder',
|
||||||
readOnly: false,
|
readOnly: false,
|
||||||
workflowCount: 0,
|
workflowCount: 0,
|
||||||
|
subFolderCount: 0,
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Project 1',
|
name: 'Project 1',
|
||||||
@@ -100,11 +102,26 @@ describe('FolderCard', () => {
|
|||||||
const { getByTestId } = renderComponent();
|
const { getByTestId } = renderComponent();
|
||||||
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
|
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
|
||||||
expect(getByTestId('folder-card-name')).toHaveTextContent(DEFAULT_FOLDER.name);
|
expect(getByTestId('folder-card-name')).toHaveTextContent(DEFAULT_FOLDER.name);
|
||||||
expect(getByTestId('folder-card-workflow-count')).toHaveTextContent('0');
|
expect(getByTestId('folder-card-workflow-count')).toHaveTextContent('2');
|
||||||
|
expect(getByTestId('folder-card-folder-count')).toHaveTextContent('2');
|
||||||
expect(getByTestId('folder-card-last-updated')).toHaveTextContent('Last updated just now');
|
expect(getByTestId('folder-card-last-updated')).toHaveTextContent('Last updated just now');
|
||||||
expect(getByTestId('folder-card-created')).toHaveTextContent('Created just now');
|
expect(getByTestId('folder-card-created')).toHaveTextContent('Created just now');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not render workflow & folder count if they are 0', () => {
|
||||||
|
const { queryByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
...DEFAULT_FOLDER,
|
||||||
|
workflowCount: 0,
|
||||||
|
subFolderCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(queryByTestId('folder-card-workflow-count')).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId('folder-card-folder-count')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('should render breadcrumbs with personal folder', () => {
|
it('should render breadcrumbs with personal folder', () => {
|
||||||
const { getByTestId } = renderComponent();
|
const { getByTestId } = renderComponent();
|
||||||
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
|
expect(getByTestId('folder-card-icon')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div data-test-id="folder-card">
|
||||||
<router-link :to="cardUrl" @click="() => emit('folderOpened', { folder: props.data })">
|
<router-link :to="cardUrl" @click="() => emit('folderOpened', { folder: props.data })">
|
||||||
<n8n-card :class="$style.card">
|
<n8n-card :class="$style.card">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
@@ -99,12 +99,24 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<div :class="$style['card-footer']">
|
<div :class="$style['card-footer']">
|
||||||
<n8n-text
|
<n8n-text
|
||||||
|
v-if="data.workflowCount > 0"
|
||||||
|
size="small"
|
||||||
|
color="text-light"
|
||||||
|
:class="[$style['info-cell'], $style['info-cell--workflow-count']]"
|
||||||
|
data-test-id="folder-card-folder-count"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
i18n.baseText('generic.workflow', { interpolate: { count: data.workflowCount } })
|
||||||
|
}}
|
||||||
|
</n8n-text>
|
||||||
|
<n8n-text
|
||||||
|
v-if="data.subFolderCount > 0"
|
||||||
size="small"
|
size="small"
|
||||||
color="text-light"
|
color="text-light"
|
||||||
:class="[$style['info-cell'], $style['info-cell--workflow-count']]"
|
:class="[$style['info-cell'], $style['info-cell--workflow-count']]"
|
||||||
data-test-id="folder-card-workflow-count"
|
data-test-id="folder-card-workflow-count"
|
||||||
>
|
>
|
||||||
{{ data.workflowCount }} {{ i18n.baseText('generic.workflows') }}
|
{{ i18n.baseText('generic.folder', { interpolate: { count: data.subFolderCount } }) }}
|
||||||
</n8n-text>
|
</n8n-text>
|
||||||
<n8n-text
|
<n8n-text
|
||||||
size="small"
|
size="small"
|
||||||
@@ -140,7 +152,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
|||||||
@item-selected="onBreadcrumbsItemClick"
|
@item-selected="onBreadcrumbsItemClick"
|
||||||
>
|
>
|
||||||
<template v-if="data.homeProject" #prepend>
|
<template v-if="data.homeProject" #prepend>
|
||||||
<div :class="$style['home-project']">
|
<div :class="$style['home-project']" data-test-id="folder-card-home-project">
|
||||||
<n8n-link :to="`/projects/${data.homeProject.id}`">
|
<n8n-link :to="`/projects/${data.homeProject.id}`">
|
||||||
<ProjectIcon :icon="projectIcon" :border-less="true" size="mini" />
|
<ProjectIcon :icon="projectIcon" :border-less="true" size="mini" />
|
||||||
<n8n-text size="small" :compact="true" :bold="true" color="text-base">
|
<n8n-text size="small" :compact="true" :bold="true" color="text-base">
|
||||||
@@ -207,7 +219,7 @@ const onBreadcrumbsItemClick = async (item: PathItem) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--spacing-3xs);
|
gap: var(--spacing-3xs);
|
||||||
color: var(--color-text-dark);
|
color: var(—color-text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
@include mixins.breakpoint('sm-and-down') {
|
@include mixins.breakpoint('sm-and-down') {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
PROJECT_MOVE_RESOURCE_MODAL,
|
PROJECT_MOVE_RESOURCE_MODAL,
|
||||||
PROMPT_MFA_CODE_MODAL_KEY,
|
PROMPT_MFA_CODE_MODAL_KEY,
|
||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from '@/components/AboutModal.vue';
|
import AboutModal from '@/components/AboutModal.vue';
|
||||||
@@ -280,5 +281,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
|||||||
<CommunityPlusEnrollmentModal :modal-name="modalName" :data="data" />
|
<CommunityPlusEnrollmentModal :modal-name="modalName" :data="data" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="DELETE_FOLDER_MODAL_KEY">
|
||||||
|
<template #default="{ modalName, activeId, data }">
|
||||||
|
<DeleteFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const showSettings = computed(
|
|||||||
|
|
||||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||||
const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled);
|
const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled);
|
||||||
|
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
|
||||||
|
|
||||||
const ACTION_TYPES = {
|
const ACTION_TYPES = {
|
||||||
WORKFLOW: 'workflow',
|
WORKFLOW: 'workflow',
|
||||||
@@ -84,7 +85,7 @@ const menu = computed(() => {
|
|||||||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
|
!getResourcePermissions(homeProject.value?.scopes).credential.create,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (isFoldersFeatureEnabled.value) {
|
if (isFoldersFeatureEnabled.value && !isOverviewPage.value) {
|
||||||
items.push({
|
items.push({
|
||||||
value: ACTION_TYPES.FOLDER,
|
value: ACTION_TYPES.FOLDER,
|
||||||
label: i18n.baseText('projects.header.create.folder'),
|
label: i18n.baseText('projects.header.create.folder'),
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export const PROJECT_MOVE_RESOURCE_MODAL = 'projectMoveResourceModal';
|
|||||||
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
|
export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
|
||||||
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||||
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
||||||
|
export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"generic.annotationData": "Highlighted data",
|
"generic.annotationData": "Highlighted data",
|
||||||
"generic.any": "Any",
|
"generic.any": "Any",
|
||||||
"generic.cancel": "Cancel",
|
"generic.cancel": "Cancel",
|
||||||
|
"generic.open": "Open",
|
||||||
"generic.close": "Close",
|
"generic.close": "Close",
|
||||||
"generic.confirm": "Confirm",
|
"generic.confirm": "Confirm",
|
||||||
"generic.create": "Create",
|
"generic.create": "Create",
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
"generic.filtersApplied": "Filters are currently applied.",
|
"generic.filtersApplied": "Filters are currently applied.",
|
||||||
"generic.field": "field",
|
"generic.field": "field",
|
||||||
"generic.fields": "fields",
|
"generic.fields": "fields",
|
||||||
|
"generic.folder": "Folder | {count} Folder | {count} Folders",
|
||||||
"generic.learnMore": "Learn more",
|
"generic.learnMore": "Learn more",
|
||||||
"generic.reset": "Reset",
|
"generic.reset": "Reset",
|
||||||
"generic.resetAllFilters": "Reset all filters",
|
"generic.resetAllFilters": "Reset all filters",
|
||||||
@@ -89,6 +91,7 @@
|
|||||||
"generic.variable": "Variable | {count} Variables",
|
"generic.variable": "Variable | {count} Variables",
|
||||||
"generic.viewDocs": "View docs",
|
"generic.viewDocs": "View docs",
|
||||||
"generic.workflows": "Workflows",
|
"generic.workflows": "Workflows",
|
||||||
|
"generic.rename": "Rename",
|
||||||
"about.aboutN8n": "About n8n",
|
"about.aboutN8n": "About n8n",
|
||||||
"about.close": "Close",
|
"about.close": "Close",
|
||||||
"about.license": "License",
|
"about.license": "License",
|
||||||
@@ -891,12 +894,35 @@
|
|||||||
"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.workflow": "Create workflow",
|
||||||
|
"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.success.title": "Folder created",
|
"folders.add.success.title": "Folder created",
|
||||||
"folders.add.success.message": "<a href=\"{link}\">Open {name}</a> now",
|
"folders.add.success.message": "<a href=\"{link}\">Open {name}</a> now",
|
||||||
"folders.add.invalidName.message": "Please provide a valid folder name",
|
"folders.invalidName.message": "Please provide a valid folder name",
|
||||||
|
"folders.delete.confirm.title": "Delete \"{folderName}\"",
|
||||||
|
"folders.delete.typeToConfirm": "delete {folderName}",
|
||||||
|
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
|
||||||
|
"folders.delete.success.message": "Folder deleted",
|
||||||
|
"folders.delete.confirmActionAfterDelete": "What should we do with the data in this folder?",
|
||||||
|
"folder.delete.modal.confirmation": "What should we do with {folders} {workflows} in this folder?",
|
||||||
|
"folder.count": "the {count} folder | the {count} folders",
|
||||||
|
"workflow.count": "the {count} workflow | the {count} workflows",
|
||||||
|
"folder.and.workflow.separator": "and",
|
||||||
|
"folders.delete.action": "Delete all workflows and subfolders",
|
||||||
|
"folders.delete.error.message": "Problem while deleting folder",
|
||||||
|
"folders.delete.confirmation.message": "Type \"delete {folderName}\" to confirm",
|
||||||
|
"folders.transfer.confirm.message": "Data transferred to \"{folderName}\"",
|
||||||
|
"folders.transfer.action": "Transfer workflows and subfolders to another folder",
|
||||||
|
"folders.transfer.selectFolder": "Folder to to transfer to",
|
||||||
|
"folders.transfer.select.placeholder": "Select folder",
|
||||||
|
"folders.rename.message": "Rename \"{folderName}\"",
|
||||||
|
"folders.rename.error.title": "Problem renaming folder",
|
||||||
|
"folders.rename.success.message": "Folder renamed",
|
||||||
|
"folders.not.found.message": "Folder not found",
|
||||||
"generic.oauth1Api": "OAuth1 API",
|
"generic.oauth1Api": "OAuth1 API",
|
||||||
"generic.oauth2Api": "OAuth2 API",
|
"generic.oauth2Api": "OAuth2 API",
|
||||||
"genericHelpers.loading": "Loading",
|
"genericHelpers.loading": "Loading",
|
||||||
@@ -2382,6 +2408,7 @@
|
|||||||
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
|
"workflows.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create workflows",
|
||||||
"workflows.empty.easyAI": "Test a ready-to-go AI Agent example",
|
"workflows.empty.easyAI": "Test a ready-to-go AI Agent example",
|
||||||
"workflows.list.easyAI": "Test the power of AI in n8n with this ready-to-go AI Agent Workflow",
|
"workflows.list.easyAI": "Test the power of AI in n8n with this ready-to-go AI Agent Workflow",
|
||||||
|
"workflows.list.error.fetching": "Error fetching workflows",
|
||||||
"workflows.shareModal.title": "Share '{name}'",
|
"workflows.shareModal.title": "Share '{name}'",
|
||||||
"workflows.shareModal.title.static": "Shared with {projectName}",
|
"workflows.shareModal.title.static": "Shared with {projectName}",
|
||||||
"workflows.shareModal.select.placeholder": "Add users...",
|
"workflows.shareModal.select.placeholder": "Add users...",
|
||||||
|
|||||||
@@ -90,6 +90,24 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteFoldersFromCache = (folderIds: string[]) => {
|
||||||
|
folderIds.forEach((folderId) => {
|
||||||
|
delete breadcrumbsCache.value[folderId];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function deleteFolder(projectId: string, folderId: string, newParentId?: string) {
|
||||||
|
await workflowsApi.deleteFolder(rootStore.restApiContext, projectId, folderId, newParentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameFolder(projectId: string, folderId: string, name: string) {
|
||||||
|
await workflowsApi.renameFolder(rootStore.restApiContext, projectId, folderId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectFolders(projectId: string) {
|
||||||
|
return await workflowsApi.getProjectFolders(rootStore.restApiContext, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchTotalWorkflowsAndFoldersCount,
|
fetchTotalWorkflowsAndFoldersCount,
|
||||||
breadcrumbsCache,
|
breadcrumbsCache,
|
||||||
@@ -98,5 +116,9 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
createFolder,
|
createFolder,
|
||||||
getFolderPath,
|
getFolderPath,
|
||||||
totalWorkflowCount,
|
totalWorkflowCount,
|
||||||
|
deleteFolder,
|
||||||
|
deleteFoldersFromCache,
|
||||||
|
renameFolder,
|
||||||
|
fetchProjectFolders,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
PROMPT_MFA_CODE_MODAL_KEY,
|
PROMPT_MFA_CODE_MODAL_KEY,
|
||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
||||||
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
INodeUi,
|
INodeUi,
|
||||||
@@ -66,6 +67,7 @@ import {
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import type { Connection } from '@vue-flow/core';
|
import type { Connection } from '@vue-flow/core';
|
||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
|
|
||||||
let savedTheme: ThemeOption = 'system';
|
let savedTheme: ThemeOption = 'system';
|
||||||
|
|
||||||
@@ -156,6 +158,16 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
activeId: null,
|
activeId: null,
|
||||||
showAuthSelector: false,
|
showAuthSelector: false,
|
||||||
} as ModalState,
|
} as ModalState,
|
||||||
|
[DELETE_FOLDER_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
activeId: null,
|
||||||
|
data: {
|
||||||
|
content: {
|
||||||
|
workflowCount: 0,
|
||||||
|
subFolderCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const modalStack = ref<string[]>([]);
|
const modalStack = ref<string[]>([]);
|
||||||
@@ -477,6 +489,15 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
openModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
|
openModal(COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDeleteFolderModal = (
|
||||||
|
id: string,
|
||||||
|
workflowListEventBus: EventBus,
|
||||||
|
content: { workflowCount: number; subFolderCount: number },
|
||||||
|
) => {
|
||||||
|
setActiveId(DELETE_FOLDER_MODAL_KEY, id);
|
||||||
|
openModalWithData({ name: DELETE_FOLDER_MODAL_KEY, data: { workflowListEventBus, content } });
|
||||||
|
};
|
||||||
|
|
||||||
const addActiveAction = (action: string) => {
|
const addActiveAction = (action: string) => {
|
||||||
if (!activeActions.value.includes(action)) {
|
if (!activeActions.value.includes(action)) {
|
||||||
activeActions.value.push(action);
|
activeActions.value.push(action);
|
||||||
@@ -648,6 +669,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
deleteNotificationsForView,
|
deleteNotificationsForView,
|
||||||
resetLastInteractedWith,
|
resetLastInteractedWith,
|
||||||
setProcessingExecutionResults,
|
setProcessingExecutionResults,
|
||||||
|
openDeleteFolderModal,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -311,6 +311,7 @@ describe('Folders', () => {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
workflowCount: 1,
|
workflowCount: 1,
|
||||||
|
subFolderCount: 0,
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Project 1',
|
name: 'Project 1',
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
WorkflowListResource,
|
WorkflowListResource,
|
||||||
WorkflowListItem,
|
WorkflowListItem,
|
||||||
FolderPathItem,
|
FolderPathItem,
|
||||||
|
FolderListItem,
|
||||||
} from '@/Interface';
|
} from '@/Interface';
|
||||||
import { useUIStore } from '@/stores/ui.store';
|
import { useUIStore } from '@/stores/ui.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
@@ -126,45 +127,35 @@ const currentFolderId = ref<string | null>(null);
|
|||||||
*/
|
*/
|
||||||
const folderActions = ref<Array<UserAction & { onlyAvailableOn?: 'mainBreadcrumbs' | 'card' }>>([
|
const folderActions = ref<Array<UserAction & { onlyAvailableOn?: 'mainBreadcrumbs' | 'card' }>>([
|
||||||
{
|
{
|
||||||
label: 'Open',
|
label: i18n.baseText('generic.open'),
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.OPEN,
|
value: FOLDER_LIST_ITEM_ACTIONS.OPEN,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
onlyAvailableOn: 'card',
|
onlyAvailableOn: 'card',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Create Folder',
|
label: i18n.baseText('folders.actions.create'),
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.CREATE,
|
value: FOLDER_LIST_ITEM_ACTIONS.CREATE,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Create Workflow',
|
label: i18n.baseText('folders.actions.create.workflow'),
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW,
|
value: FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Rename',
|
label: i18n.baseText('generic.rename'),
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.RENAME,
|
value: FOLDER_LIST_ITEM_ACTIONS.RENAME,
|
||||||
disabled: true,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Move to Folder',
|
label: i18n.baseText('folders.actions.moveToFolder'),
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.MOVE,
|
value: FOLDER_LIST_ITEM_ACTIONS.MOVE,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Change Owner',
|
label: i18n.baseText('generic.delete'),
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.CHOWN,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Manage Tags',
|
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.TAGS,
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Delete',
|
|
||||||
value: FOLDER_LIST_ITEM_ACTIONS.DELETE,
|
value: FOLDER_LIST_ITEM_ACTIONS.DELETE,
|
||||||
disabled: true,
|
disabled: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const folderCardActions = computed(() =>
|
const folderCardActions = computed(() =>
|
||||||
@@ -218,6 +209,7 @@ const workflowListResources = computed<Resource[]>(() => {
|
|||||||
homeProject: resource.homeProject,
|
homeProject: resource.homeProject,
|
||||||
sharedWithProjects: resource.sharedWithProjects,
|
sharedWithProjects: resource.sharedWithProjects,
|
||||||
workflowCount: resource.workflowCount,
|
workflowCount: resource.workflowCount,
|
||||||
|
subFolderCount: resource.subFolderCount,
|
||||||
parentFolder: resource.parentFolder,
|
parentFolder: resource.parentFolder,
|
||||||
} as FolderResource;
|
} as FolderResource;
|
||||||
} else {
|
} else {
|
||||||
@@ -280,7 +272,7 @@ const emptyListDescription = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WATCHERS AND STORE SUBSCRIPTIONS
|
* WATCHERS, STORE SUBSCRIPTIONS AND EVENT BUS HANDLERS
|
||||||
*/
|
*/
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -303,6 +295,21 @@ sourceControlStore.$onAction(({ name, after }) => {
|
|||||||
after(async () => await initialize());
|
after(async () => await initialize());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onFolderDeleted = async (payload: { folderId: string }) => {
|
||||||
|
const folderInfo = foldersStore.getCachedFolder(payload.folderId);
|
||||||
|
foldersStore.deleteFoldersFromCache([payload.folderId, folderInfo?.parentFolder ?? '']);
|
||||||
|
// If the deleted folder is the current folder, navigate to the parent folder
|
||||||
|
|
||||||
|
if (currentFolderId.value === payload.folderId) {
|
||||||
|
void router.push({
|
||||||
|
name: VIEWS.PROJECTS_FOLDERS,
|
||||||
|
params: { projectId: route.params.projectId, folderId: folderInfo?.parentFolder ?? '' },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetchWorkflows();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LIFE-CYCLE HOOKS
|
* LIFE-CYCLE HOOKS
|
||||||
*/
|
*/
|
||||||
@@ -313,11 +320,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
workflowListEventBus.on('resource-moved', fetchWorkflows);
|
workflowListEventBus.on('resource-moved', fetchWorkflows);
|
||||||
workflowListEventBus.on('workflow-duplicated', fetchWorkflows);
|
workflowListEventBus.on('workflow-duplicated', fetchWorkflows);
|
||||||
|
workflowListEventBus.on('folder-deleted', onFolderDeleted);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
workflowListEventBus.off('resource-moved', fetchWorkflows);
|
workflowListEventBus.off('resource-moved', fetchWorkflows);
|
||||||
workflowListEventBus.off('workflow-duplicated', fetchWorkflows);
|
workflowListEventBus.off('workflow-duplicated', fetchWorkflows);
|
||||||
|
workflowListEventBus.off('folder-deleted', onFolderDeleted);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -328,9 +337,8 @@ onBeforeUnmount(() => {
|
|||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
await setFiltersFromQueryString();
|
await setFiltersFromQueryString();
|
||||||
if (!route.params.folderId) {
|
|
||||||
currentFolderId.value = null;
|
currentFolderId.value = route.params.folderId as string | null;
|
||||||
}
|
|
||||||
const [, resourcesPage] = await Promise.all([
|
const [, resourcesPage] = await Promise.all([
|
||||||
usersStore.fetchUsers(),
|
usersStore.fetchUsers(),
|
||||||
fetchWorkflows(),
|
fetchWorkflows(),
|
||||||
@@ -357,41 +365,63 @@ const fetchWorkflows = async () => {
|
|||||||
const homeProjectFilter = filters.value.homeProject || undefined;
|
const homeProjectFilter = filters.value.homeProject || undefined;
|
||||||
const parentFolder = (route.params?.folderId as string) || undefined;
|
const parentFolder = (route.params?.folderId as string) || undefined;
|
||||||
|
|
||||||
const fetchedResources = await workflowsStore.fetchWorkflowsPage(
|
const tags = filters.value.tags.length
|
||||||
routeProjectId ?? homeProjectFilter,
|
? filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name)
|
||||||
currentPage.value,
|
: [];
|
||||||
pageSize.value,
|
const activeFilter =
|
||||||
currentSort.value,
|
filters.value.status === StatusFilter.ALL
|
||||||
{
|
? undefined
|
||||||
name: filters.value.search || undefined,
|
: filters.value.status === StatusFilter.ACTIVE;
|
||||||
active:
|
|
||||||
filters.value.status === StatusFilter.ALL
|
|
||||||
? undefined
|
|
||||||
: filters.value.status === StatusFilter.ACTIVE,
|
|
||||||
tags: filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name),
|
|
||||||
parentFolderId: parentFolder ?? '0', // 0 is the root folder in the API
|
|
||||||
},
|
|
||||||
showFolders.value,
|
|
||||||
);
|
|
||||||
foldersStore.cacheFolders(
|
|
||||||
fetchedResources
|
|
||||||
.filter((resource) => resource.resource === 'folder')
|
|
||||||
.map((r) => ({ id: r.id, name: r.name, parentFolder: r.parentFolder?.id })),
|
|
||||||
);
|
|
||||||
const isCurrentFolderCached = foldersStore.breadcrumbsCache[parentFolder ?? ''] !== undefined;
|
|
||||||
const needToFetchFolderPath = parentFolder && !isCurrentFolderCached && routeProjectId;
|
|
||||||
if (needToFetchFolderPath) {
|
|
||||||
breadcrumbsLoading.value = true;
|
|
||||||
await foldersStore.getFolderPath(routeProjectId, parentFolder);
|
|
||||||
currentFolderId.value = parentFolder;
|
|
||||||
breadcrumbsLoading.value = false;
|
|
||||||
}
|
|
||||||
await foldersStore.fetchTotalWorkflowsAndFoldersCount(routeProjectId);
|
|
||||||
|
|
||||||
delayedLoading.cancel();
|
// Only fetch folders if showFolders is enabled and there are not tags or active filter applied
|
||||||
workflowsAndFolders.value = fetchedResources;
|
const fetchFolders = showFolders.value && !tags.length && activeFilter === undefined;
|
||||||
loading.value = false;
|
|
||||||
return fetchedResources;
|
try {
|
||||||
|
const fetchedResources = await workflowsStore.fetchWorkflowsPage(
|
||||||
|
routeProjectId ?? homeProjectFilter,
|
||||||
|
currentPage.value,
|
||||||
|
pageSize.value,
|
||||||
|
currentSort.value,
|
||||||
|
{
|
||||||
|
name: filters.value.search || undefined,
|
||||||
|
active: activeFilter,
|
||||||
|
tags,
|
||||||
|
parentFolderId: parentFolder ?? '0', // 0 is the root folder in the API
|
||||||
|
},
|
||||||
|
fetchFolders,
|
||||||
|
);
|
||||||
|
|
||||||
|
foldersStore.cacheFolders(
|
||||||
|
fetchedResources
|
||||||
|
.filter((resource) => resource.resource === 'folder')
|
||||||
|
.map((r) => ({ id: r.id, name: r.name, parentFolder: r.parentFolder?.id })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCurrentFolderCached = foldersStore.breadcrumbsCache[parentFolder ?? ''] !== undefined;
|
||||||
|
const needToFetchFolderPath = parentFolder && !isCurrentFolderCached && routeProjectId;
|
||||||
|
|
||||||
|
if (needToFetchFolderPath) {
|
||||||
|
breadcrumbsLoading.value = true;
|
||||||
|
await foldersStore.getFolderPath(routeProjectId, parentFolder);
|
||||||
|
breadcrumbsLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await foldersStore.fetchTotalWorkflowsAndFoldersCount(routeProjectId);
|
||||||
|
|
||||||
|
workflowsAndFolders.value = fetchedResources;
|
||||||
|
return fetchedResources;
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('workflows.list.error.fetching'));
|
||||||
|
// redirect to the project page if the folder is not found
|
||||||
|
void router.push({ name: VIEWS.PROJECTS_FOLDERS, params: { projectId: routeProjectId } });
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
delayedLoading.cancel();
|
||||||
|
loading.value = false;
|
||||||
|
if (breadcrumbsLoading.value) {
|
||||||
|
breadcrumbsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter and sort methods
|
// Filter and sort methods
|
||||||
@@ -608,6 +638,44 @@ const onWorkflowActiveToggle = (data: { id: string; active: boolean }) => {
|
|||||||
workflow.active = data.active;
|
workflow.active = data.active;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getFolderListItem = (folderId: string): FolderListItem | undefined => {
|
||||||
|
return workflowsAndFolders.value.find(
|
||||||
|
(resource): resource is FolderListItem =>
|
||||||
|
resource.resource === 'folder' && resource.id === folderId,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// TODO: This will only count the workflows and folders in the current page
|
||||||
|
// Check if we need to add counts to /tree endpoint or not show them in modal
|
||||||
|
const getCurrentFolderWorkflowCount = () => {
|
||||||
|
const workflows = workflowsAndFolders.value.filter(
|
||||||
|
(resource): resource is WorkflowListItem => resource.resource === 'workflow',
|
||||||
|
);
|
||||||
|
return workflows.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentFolderSubFolderCount = () => {
|
||||||
|
const folders = workflowsAndFolders.value.filter(
|
||||||
|
(resource): resource is FolderListItem => resource.resource === 'folder',
|
||||||
|
);
|
||||||
|
return folders.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFolderContent = (folderId: string) => {
|
||||||
|
const folderListItem = getFolderListItem(folderId);
|
||||||
|
if (!folderListItem) {
|
||||||
|
toast.showMessage({
|
||||||
|
title: i18n.baseText('folders.delete.error.message'),
|
||||||
|
message: i18n.baseText('folders.not.found.message'),
|
||||||
|
type: 'error',
|
||||||
|
});
|
||||||
|
return { workflowCount: 0, subFolderCount: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workflowCount: folderListItem.workflowCount,
|
||||||
|
subFolderCount: folderListItem.subFolderCount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Breadcrumbs methods
|
// Breadcrumbs methods
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -720,6 +788,16 @@ const onBreadCrumbsAction = async (action: string) => {
|
|||||||
case FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW:
|
case FOLDER_LIST_ITEM_ACTIONS.CREATE_WORKFLOW:
|
||||||
addWorkflow();
|
addWorkflow();
|
||||||
break;
|
break;
|
||||||
|
case FOLDER_LIST_ITEM_ACTIONS.DELETE:
|
||||||
|
if (!route.params.folderId) return;
|
||||||
|
const subFolderCount = getCurrentFolderSubFolderCount();
|
||||||
|
const workflowCount = getCurrentFolderWorkflowCount();
|
||||||
|
await deleteFolder(route.params.folderId as string, workflowCount, subFolderCount);
|
||||||
|
break;
|
||||||
|
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
|
||||||
|
if (!route.params.folderId) return;
|
||||||
|
await renameFolder(route.params.folderId as string);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -745,6 +823,14 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
|
|||||||
query: { projectId: route.params?.projectId, parentFolderId: clickedFolder.id },
|
query: { projectId: route.params?.projectId, parentFolderId: clickedFolder.id },
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case FOLDER_LIST_ITEM_ACTIONS.DELETE: {
|
||||||
|
const content = getFolderContent(clickedFolder.id);
|
||||||
|
await deleteFolder(clickedFolder.id, content.workflowCount, content.subFolderCount);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
|
||||||
|
await renameFolder(clickedFolder.id);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -758,7 +844,7 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
{
|
{
|
||||||
confirmButtonText: i18n.baseText('generic.create'),
|
confirmButtonText: i18n.baseText('generic.create'),
|
||||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||||
inputErrorMessage: i18n.baseText('folders.add.invalidName.message'),
|
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
||||||
inputValue: '',
|
inputValue: '',
|
||||||
inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/,
|
inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/,
|
||||||
customClass: 'add-folder-modal',
|
customClass: 'add-folder-modal',
|
||||||
@@ -774,7 +860,7 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
parent.type === 'folder' ? parent.id : undefined,
|
parent.type === 'folder' ? parent.id : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
let newFolderURL = `/projects/${route.params.projectId}`;
|
let newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
|
||||||
if (newFolder.parentFolder) {
|
if (newFolder.parentFolder) {
|
||||||
newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
|
newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
|
||||||
}
|
}
|
||||||
@@ -800,8 +886,12 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
homeProject: projectsStore.currentProject as ProjectSharingData,
|
homeProject: projectsStore.currentProject as ProjectSharingData,
|
||||||
sharedWithProjects: [],
|
sharedWithProjects: [],
|
||||||
workflowCount: 0,
|
workflowCount: 0,
|
||||||
|
subFolderCount: 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
foldersStore.cacheFolders([
|
||||||
|
{ id: newFolder.id, name: newFolder.name, parentFolder: currentFolder.value?.id },
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Else fetch again with same filters & pagination applied
|
// Else fetch again with same filters & pagination applied
|
||||||
await fetchWorkflows();
|
await fetchWorkflows();
|
||||||
@@ -812,6 +902,39 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renameFolder = async (folderId: string) => {
|
||||||
|
const folder = foldersStore.getCachedFolder(folderId);
|
||||||
|
if (!folder || !currentProject.value) return;
|
||||||
|
const promptResponsePromise = message.prompt(
|
||||||
|
i18n.baseText('folders.rename.message', { interpolate: { folderName: folder.name } }),
|
||||||
|
{
|
||||||
|
confirmButtonText: i18n.baseText('generic.rename'),
|
||||||
|
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||||
|
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
||||||
|
inputValue: folder.name,
|
||||||
|
inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/,
|
||||||
|
customClass: 'rename-folder-modal',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const promptResponse = await promptResponsePromise;
|
||||||
|
if (promptResponse.action === MODAL_CONFIRM) {
|
||||||
|
const newFolderName = promptResponse.value;
|
||||||
|
try {
|
||||||
|
await foldersStore.renameFolder(currentProject.value?.id, folderId, newFolderName);
|
||||||
|
foldersStore.breadcrumbsCache[folderId].name = newFolderName;
|
||||||
|
toast.showMessage({
|
||||||
|
title: i18n.baseText('folders.rename.success.message', {
|
||||||
|
interpolate: { folderName: newFolderName },
|
||||||
|
}),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
await fetchWorkflows();
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('folders.rename.error.title'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createFolderInCurrent = async () => {
|
const createFolderInCurrent = async () => {
|
||||||
if (!route.params.projectId) return;
|
if (!route.params.projectId) return;
|
||||||
const currentParent = currentFolder.value?.name || projectName.value;
|
const currentParent = currentFolder.value?.name || projectName.value;
|
||||||
@@ -822,6 +945,22 @@ const createFolderInCurrent = async () => {
|
|||||||
type: currentFolder.value ? 'folder' : 'project',
|
type: currentFolder.value ? 'folder' : 'project',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteFolder = async (folderId: string, workflowCount: number, subFolderCount: number) => {
|
||||||
|
if (subFolderCount || workflowCount) {
|
||||||
|
uiStore.openDeleteFolderModal(folderId, workflowListEventBus, {
|
||||||
|
workflowCount,
|
||||||
|
subFolderCount,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await foldersStore.deleteFolder(route.params.projectId as string, folderId);
|
||||||
|
toast.showMessage({
|
||||||
|
title: i18n.baseText('folders.delete.success.message'),
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
await onFolderDeleted({ folderId });
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -903,7 +1042,11 @@ const createFolderInCurrent = async () => {
|
|||||||
<div v-if="breadcrumbsLoading" :class="$style['breadcrumbs-loading']">
|
<div v-if="breadcrumbsLoading" :class="$style['breadcrumbs-loading']">
|
||||||
<n8n-loading :loading="breadcrumbsLoading" :rows="1" variant="p" />
|
<n8n-loading :loading="breadcrumbsLoading" :rows="1" variant="p" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="showFolders && currentFolder" :class="$style['breadcrumbs-container']">
|
<div
|
||||||
|
v-else-if="showFolders && currentFolder"
|
||||||
|
:class="$style['breadcrumbs-container']"
|
||||||
|
data-test-id="main-breadcrumbs"
|
||||||
|
>
|
||||||
<FolderBreadcrumbs
|
<FolderBreadcrumbs
|
||||||
:breadcrumbs="mainBreadcrumbs"
|
:breadcrumbs="mainBreadcrumbs"
|
||||||
:actions="mainBreadcrumbsActions"
|
:actions="mainBreadcrumbsActions"
|
||||||
|
|||||||
Reference in New Issue
Block a user