feat(editor): Show workflow breadcrumbs in canvas (#14710)

This commit is contained in:
Milorad FIlipović
2025-04-28 13:37:55 +02:00
committed by GitHub
parent be53453def
commit 46df8b47d6
26 changed files with 911 additions and 238 deletions

View File

@@ -76,7 +76,11 @@ export function getVisibleListBreadcrumbs() {
} }
export function getCurrentBreadcrumb() { export function getCurrentBreadcrumb() {
return getListBreadcrumbs().findChildByTestId('breadcrumbs-item-current'); return getListBreadcrumbs().findChildByTestId('breadcrumbs-item-current').find('input');
}
export function getCurrentBreadcrumbText() {
return getCurrentBreadcrumb().invoke('val');
} }
export function getMainBreadcrumbsEllipsis() { export function getMainBreadcrumbsEllipsis() {
@@ -117,6 +121,11 @@ export function getListActionsToggle() {
return cy.getByTestId('folder-breadcrumbs-actions'); return cy.getByTestId('folder-breadcrumbs-actions');
} }
export function getCanvasBreadcrumbs() {
cy.getByTestId('canvas-breadcrumbs').should('exist');
return cy.getByTestId('canvas-breadcrumbs').findChildByTestId('folder-breadcrumbs');
}
export function getListActionItem(name: string) { export function getListActionItem(name: string) {
return cy return cy
.getByTestId('folder-breadcrumbs-actions') .getByTestId('folder-breadcrumbs-actions')
@@ -127,6 +136,10 @@ export function getListActionItem(name: string) {
}); });
} }
export function getInlineEditInput() {
return cy.getByTestId('inline-edit-input');
}
export function getFolderCardActionToggle(folderName: string) { export function getFolderCardActionToggle(folderName: string) {
return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]'); return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]');
} }
@@ -303,7 +316,9 @@ export function renameFolderFromListActions(folderName: string, newName: string)
getFolderCard(folderName).click(); getFolderCard(folderName).click();
getListActionsToggle().click(); getListActionsToggle().click();
getListActionItem('rename').click(); getListActionItem('rename').click();
renameFolder(newName); getInlineEditInput().should('be.visible');
getInlineEditInput().type(newName, { delay: 50 });
successToast().should('exist');
} }
export function renameFolderFromCardActions(folderName: string, newName: string) { export function renameFolderFromCardActions(folderName: string, newName: string) {
@@ -372,12 +387,9 @@ export function deleteAndTransferFolderContentsFromCardDropdown(
export function deleteAndTransferFolderContentsFromListDropdown(destinationName: string) { export function deleteAndTransferFolderContentsFromListDropdown(destinationName: string) {
getListActionsToggle().click(); getListActionsToggle().click();
getListActionItem('delete').click(); getListActionItem('delete').click();
getCurrentBreadcrumb() getCurrentBreadcrumbText().then((currentFolderName) => {
.find('span') deleteFolderAndMoveContents(String(currentFolderName), destinationName);
.invoke('text') });
.then((currentFolderName) => {
deleteFolderAndMoveContents(currentFolderName, destinationName);
});
} }
export function createNewProject(projectName: string, options: { openAfterCreate?: boolean } = {}) { export function createNewProject(projectName: string, options: { openAfterCreate?: boolean } = {}) {

View File

@@ -19,7 +19,8 @@ import {
duplicateWorkflowFromCardActions, duplicateWorkflowFromCardActions,
duplicateWorkflowFromWorkflowPage, duplicateWorkflowFromWorkflowPage,
getAddResourceDropdown, getAddResourceDropdown,
getCurrentBreadcrumb, getCanvasBreadcrumbs,
getCurrentBreadcrumbText,
getFolderCard, getFolderCard,
getFolderCardActionItem, getFolderCardActionItem,
getFolderCardActionToggle, getFolderCardActionToggle,
@@ -75,7 +76,7 @@ describe('Folders', () => {
getFolderCards().should('have.length.greaterThan', 0); getFolderCards().should('have.length.greaterThan', 0);
// Clicking on the success toast should navigate to the folder // Clicking on the success toast should navigate to the folder
successToast().find('a').click(); successToast().find('a').click();
getCurrentBreadcrumb().should('contain.text', 'My Folder'); getCurrentBreadcrumbText().should('equal', 'My Folder');
// 2. In a folder // 2. In a folder
createFolderFromListHeaderButton('My Folder 2'); createFolderFromListHeaderButton('My Folder 2');
getFolderCard('My Folder 2').should('exist'); getFolderCard('My Folder 2').should('exist');
@@ -116,7 +117,7 @@ describe('Folders', () => {
getFolderCards().should('have.length.greaterThan', 0); getFolderCards().should('have.length.greaterThan', 0);
// Clicking on the success toast should navigate to the folder // Clicking on the success toast should navigate to the folder
successToast().contains('My Folder 2').find('a').contains('Open folder').click(); successToast().contains('My Folder 2').find('a').contains('Open folder').click();
getCurrentBreadcrumb().should('contain.text', 'My Folder 2'); getCurrentBreadcrumbText().should('equal', 'My Folder 2');
}); });
it('should create folder from the list header dropdown', () => { it('should create folder from the list header dropdown', () => {
@@ -137,7 +138,7 @@ describe('Folders', () => {
successToast().should('exist'); successToast().should('exist');
// Should be automatically navigated to the new folder // Should be automatically navigated to the new folder
getFolderCard('Child Folder').should('exist'); getFolderCard('Child Folder').should('exist');
getCurrentBreadcrumb().should('contain.text', 'Created from card dropdown'); getCurrentBreadcrumbText().should('equal', 'Created from card dropdown');
}); });
it('should navigate folders using breadcrumbs and dropdown menu', () => { it('should navigate folders using breadcrumbs and dropdown menu', () => {
@@ -146,15 +147,15 @@ describe('Folders', () => {
// Open folder using menu item // Open folder using menu item
getFolderCardActionToggle('Navigate Test').click(); getFolderCardActionToggle('Navigate Test').click();
getFolderCardActionItem('Navigate Test', 'open').click(); getFolderCardActionItem('Navigate Test', 'open').click();
getCurrentBreadcrumb().should('contain.text', 'Navigate Test'); getCurrentBreadcrumbText().should('equal', 'Navigate Test');
// Create new child folder and navigate to it // Create new child folder and navigate to it
createFolderFromListHeaderButton('Child Folder'); createFolderFromListHeaderButton('Child Folder');
getFolderCard('Child Folder').should('exist'); getFolderCard('Child Folder').should('exist');
getFolderCard('Child Folder').click(); getFolderCard('Child Folder').click();
getCurrentBreadcrumb().should('contain.text', 'Child Folder'); getCurrentBreadcrumbText().should('equal', 'Child Folder');
// Navigate back to parent folder using breadcrumbs // Navigate back to parent folder using breadcrumbs
getVisibleListBreadcrumbs().contains('Navigate Test').click(); getVisibleListBreadcrumbs().contains('Navigate Test').click();
getCurrentBreadcrumb().should('contain.text', 'Navigate Test'); getCurrentBreadcrumbText().should('equal', 'Navigate Test');
// Go back to home project using breadcrumbs // Go back to home project using breadcrumbs
getHomeProjectBreadcrumb().click(); getHomeProjectBreadcrumb().click();
getListBreadcrumbs().should('not.exist'); getListBreadcrumbs().should('not.exist');
@@ -168,14 +169,14 @@ describe('Folders', () => {
// One level deep: // One level deep:
// - Breadcrumbs should only show home project and current folder // - Breadcrumbs should only show home project and current folder
getHomeProjectBreadcrumb().should('exist'); getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Multi-level Test'); getCurrentBreadcrumbText().should('equal', 'Multi-level Test');
getFolderCard('Child Folder').should('exist'); getFolderCard('Child Folder').should('exist');
createFolderInsideFolder('Child Folder 2', 'Child Folder'); createFolderInsideFolder('Child Folder 2', 'Child Folder');
// Two levels deep: // Two levels deep:
// - Breadcrumbs should also show parent folder, without hidden ellipsis // - Breadcrumbs should also show parent folder, without hidden ellipsis
getHomeProjectBreadcrumb().should('exist'); getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Child Folder'); getCurrentBreadcrumbText().should('equal', 'Child Folder');
getVisibleListBreadcrumbs().should('have.length', 1); getVisibleListBreadcrumbs().should('have.length', 1);
getMainBreadcrumbsEllipsis().should('not.exist'); getMainBreadcrumbsEllipsis().should('not.exist');
@@ -202,7 +203,7 @@ describe('Folders', () => {
cy.reload(); cy.reload();
// Main list breadcrumbs should show home project, parent, grandparent, with one hidden element // Main list breadcrumbs should show home project, parent, grandparent, with one hidden element
getHomeProjectBreadcrumb().should('exist'); getHomeProjectBreadcrumb().should('exist');
getCurrentBreadcrumb().should('contain.text', 'Child Folder 2'); getCurrentBreadcrumbText().should('equal', 'Child Folder 2');
getVisibleListBreadcrumbs().should('have.length', 1); getVisibleListBreadcrumbs().should('have.length', 1);
getVisibleListBreadcrumbs().first().should('contain.text', 'Child Folder'); getVisibleListBreadcrumbs().first().should('contain.text', 'Child Folder');
getMainBreadcrumbsEllipsis().should('exist'); getMainBreadcrumbsEllipsis().should('exist');
@@ -290,6 +291,24 @@ describe('Folders', () => {
getFolderCard('Workflows go here').click(); getFolderCard('Workflows go here').click();
getWorkflowCard('Created from list breadcrumbs').should('exist'); getWorkflowCard('Created from list breadcrumbs').should('exist');
}); });
it('should show new workflow breadcrumbs correctly', () => {
goToPersonalProject();
createFolderFromProjectHeader('Workflow breadcrumbs test');
getFolderCard('Workflow breadcrumbs test').should('exist').click();
getFolderEmptyState().find('button').contains('Create Workflow').click();
// Should show breadcrumbs before and after saving new workflow
getCanvasBreadcrumbs().should('exist');
getCanvasBreadcrumbs().findChildByTestId('home-project').should('contain.text', 'Personal');
getCanvasBreadcrumbs().find('li[data-test-id="breadcrumbs-item"]').should('have.length', 1);
// Save workflow and reload
cy.getByTestId('workflow-save-button').click();
cy.reload();
// Should still show the same breadcrumbs
getCanvasBreadcrumbs().should('exist');
getCanvasBreadcrumbs().findChildByTestId('home-project').should('contain.text', 'Personal');
getCanvasBreadcrumbs().find('li[data-test-id="breadcrumbs-item"]').should('have.length', 1);
});
}); });
describe('Rename and delete folders', () => { describe('Rename and delete folders', () => {
@@ -298,7 +317,7 @@ describe('Folders', () => {
createFolderFromProjectHeader('Rename Me'); createFolderFromProjectHeader('Rename Me');
getFolderCard('Rename Me').should('exist'); getFolderCard('Rename Me').should('exist');
renameFolderFromListActions('Rename Me', 'Renamed'); renameFolderFromListActions('Rename Me', 'Renamed');
getCurrentBreadcrumb().should('contain.text', 'Renamed'); getCurrentBreadcrumbText().should('equal', 'Renamed');
}); });
it('should rename folder from card dropdown', () => { it('should rename folder from card dropdown', () => {
@@ -416,7 +435,7 @@ describe('Folders', () => {
createFolderFromProjectHeader('Destination 5'); createFolderFromProjectHeader('Destination 5');
moveFolderFromListActions('Move me too - I am empty', 'Destination 5'); moveFolderFromListActions('Move me too - I am empty', 'Destination 5');
// Since we moved the current folder, we should be in the destination folder // Since we moved the current folder, we should be in the destination folder
getCurrentBreadcrumb().should('contain.text', 'Destination 5'); getCurrentBreadcrumbText().should('equal', 'Destination 5');
}); });
it('should move folder with contents to another folder - from list dropdown', () => { it('should move folder with contents to another folder - from list dropdown', () => {
@@ -432,7 +451,7 @@ describe('Folders', () => {
// Move the folder // Move the folder
moveFolderFromListActions('Move me - I have family 2', 'Destination 6'); moveFolderFromListActions('Move me - I have family 2', 'Destination 6');
// Since we moved the current folder, we should be in the destination folder // Since we moved the current folder, we should be in the destination folder
getCurrentBreadcrumb().should('contain.text', 'Destination 6'); getCurrentBreadcrumbText().should('equal', 'Destination 6');
// Moved folder should be there // Moved folder should be there
getFolderCard('Move me - I have family 2').should('exist').click(); getFolderCard('Move me - I have family 2').should('exist').click();
// After navigating to the moved folder, both the workflow and the folder should be there // After navigating to the moved folder, both the workflow and the folder should be there
@@ -456,7 +475,7 @@ describe('Folders', () => {
getFolderCard('Move me to root').click(); getFolderCard('Move me to root').click();
getHomeProjectBreadcrumb().should('contain.text', 'Personal'); getHomeProjectBreadcrumb().should('contain.text', 'Personal');
getListBreadcrumbs().findChildByTestId('breadcrumbs-item').should('not.exist'); getListBreadcrumbs().findChildByTestId('breadcrumbs-item').should('not.exist');
getCurrentBreadcrumb().should('contain.text', 'Move me to root'); getCurrentBreadcrumbText().should('equal', 'Move me to root');
}); });
it('should move workflow from project root to folder', () => { it('should move workflow from project root to folder', () => {

View File

@@ -235,17 +235,15 @@ const handleTooltipClose = () => {
:class="{ :class="{
[$style.item]: true, [$style.item]: true,
[$style.current]: props.highlightLastItem && index === items.length - 1, [$style.current]: props.highlightLastItem && index === items.length - 1,
[$style.dragging]: props.dragActive && index !== items.length - 1, [$style.dragging]: props.dragActive,
}" }"
:title="item.label" :title="item.label"
:data-test-id="
index === items.length - 1 ? 'breadcrumbs-item-current' : 'breadcrumbs-item'
"
:data-resourceid="item.id" :data-resourceid="item.id"
data-test-id="breadcrumbs-item"
data-target="folder-breadcrumb-item" data-target="folder-breadcrumb-item"
@click.prevent="emitItemSelected(item.id)" @click.prevent="emitItemSelected(item.id)"
@mouseenter="emitItemHover(item.id)" @mouseenter="emitItemHover(item.id)"
@mouseup="index !== items.length - 1 ? onItemMouseUp(item) : {}" @mouseup="onItemMouseUp(item)"
> >
<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>
@@ -330,8 +328,6 @@ const handleTooltipClose = () => {
.hidden-items-menu { .hidden-items-menu {
display: flex; display: flex;
position: relative;
top: var(--spacing-5xs);
color: var(--color-text-base); color: var(--color-text-base);
} }
@@ -411,7 +407,7 @@ const handleTooltipClose = () => {
.item * { .item * {
color: var(--color-text-base); color: var(--color-text-base);
font-size: var(--font-size-2xs); font-size: var(--font-size-2xs);
line-height: var(--font-line-heigh-xsmall); line-height: var(--font-line-height-xsmall);
} }
.item a:hover * { .item a:hover * {
@@ -427,13 +423,14 @@ const handleTooltipClose = () => {
// Medium theme overrides // Medium theme overrides
.medium { .medium {
li { li {
padding: var(--spacing-4xs); padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
} }
.item, .item,
.item * { .item * {
color: var(--color-text-base); color: var(--color-text-base);
font-size: var(--font-size-s); font-size: var(--font-size-s);
line-height: var(--font-line-height-xsmall);
} }
.item { .item {

View File

@@ -6,13 +6,13 @@ exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = fal
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-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" title="Folder 1" data-resourceid="1" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-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" title="Folder 2" data-resourceid="2" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-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" title="Folder 3" data-resourceid="3" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li> <li class="item" title="Current" data-resourceid="4" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"
@@ -24,13 +24,13 @@ exports[`Breadcrumbs > renders custom separator correctly 1`] = `
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-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" title="Folder 1" data-resourceid="1" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">➮</li> <li class="separator">➮</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-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" title="Folder 2" data-resourceid="2" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">➮</li> <li class="separator">➮</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-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" title="Folder 3" data-resourceid="3" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">➮</li> <li class="separator">➮</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-resourceid="4" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"
@@ -42,13 +42,13 @@ exports[`Breadcrumbs > renders default version correctly 1`] = `
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-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" title="Folder 1" data-resourceid="1" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-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" title="Folder 2" data-resourceid="2" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-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" title="Folder 3" data-resourceid="3" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-resourceid="4" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"
@@ -61,13 +61,13 @@ exports[`Breadcrumbs > renders slots correctly 1`] = `
<li class="separator">/</li> <li class="separator">/</li>
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-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" title="Folder 1" data-resourceid="1" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-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" title="Folder 2" data-resourceid="2" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-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" title="Folder 3" data-resourceid="3" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-resourceid="4" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-item"><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>
@@ -80,13 +80,13 @@ exports[`Breadcrumbs > renders small version correctly 1`] = `
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<!--v-if--> <!--v-if-->
<li class="item" title="Folder 1" data-test-id="breadcrumbs-item" data-resourceid="1" data-target="folder-breadcrumb-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" title="Folder 1" data-resourceid="1" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 2" data-test-id="breadcrumbs-item" data-resourceid="2" data-target="folder-breadcrumb-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" title="Folder 2" data-resourceid="2" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item" title="Folder 3" data-test-id="breadcrumbs-item" data-resourceid="3" data-target="folder-breadcrumb-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" title="Folder 3" data-resourceid="3" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-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">/</li> <li class="separator">/</li>
<li class="item current" title="Current" data-test-id="breadcrumbs-item-current" data-resourceid="4" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li> <li class="item current" title="Current" data-resourceid="4" data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if--> <!--v-if-->
</ul> </ul>
</div>" </div>"

View File

@@ -616,7 +616,7 @@
--font-size-xl: 1.25rem; --font-size-xl: 1.25rem;
--font-size-2xl: 1.75rem; --font-size-2xl: 1.75rem;
--font-line-heigh-xsmall: 1; --font-line-height-xsmall: 1;
--font-line-height-compact: 1.25; --font-line-height-compact: 1.25;
--font-line-height-regular: 1.3; --font-line-height-regular: 1.3;
--font-line-height-loose: 1.35; --font-line-height-loose: 1.35;

View File

@@ -328,7 +328,13 @@ export interface IWorkflowDb {
versionId: string; versionId: string;
usedCredentials?: IUsedCredential[]; usedCredentials?: IUsedCredential[];
meta?: WorkflowMetadata; meta?: WorkflowMetadata;
parentFolder?: { id: string; name: string }; parentFolder?: {
id: string;
name: string;
parentFolderId: string | null;
createdAt?: string;
updatedAt?: string;
};
} }
// For workflow list we don't need the full workflow data // For workflow list we don't need the full workflow data
@@ -348,7 +354,6 @@ export type FolderShortInfo = {
id: string; id: string;
name: string; name: string;
parentFolder?: string; parentFolder?: string;
parentFolderId?: string | null;
}; };
export type BaseFolderItem = BaseResource & { export type BaseFolderItem = BaseResource & {
@@ -356,12 +361,18 @@ export type BaseFolderItem = BaseResource & {
updatedAt: string; updatedAt: string;
workflowCount: number; workflowCount: number;
subFolderCount: number; subFolderCount: number;
parentFolder?: FolderShortInfo; parentFolder?: ResourceParentFolder;
homeProject?: ProjectSharingData; homeProject?: ProjectSharingData;
sharedWithProjects?: ProjectSharingData[]; sharedWithProjects?: ProjectSharingData[];
tags?: ITag[]; tags?: ITag[];
}; };
export type ResourceParentFolder = {
id: string;
name: string;
parentFolderId: string | null;
};
export interface FolderListItem extends BaseFolderItem { export interface FolderListItem extends BaseFolderItem {
resource: 'folder'; resource: 'folder';
} }

View File

@@ -27,16 +27,13 @@ const hiddenValue = computed(() => {
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
$--horiz-padding: 15px;
.el-input { .el-input {
display: inline-grid; display: inline-grid;
font: inherit; font: inherit;
padding: 10px 0;
:deep(input) { :deep(input) {
border: 1px solid transparent; border: 1px solid transparent;
padding: 0 $--horiz-padding - 2px; // -2px for borders padding: var(--spacing-3xs) calc(var(--spacing-3xs) - 2px); // -2px for borders
width: 100%; width: 100%;
grid-area: 1 / 2; grid-area: 1 / 2;
font: inherit; font: inherit;
@@ -48,7 +45,7 @@ $--horiz-padding: 15px;
content: attr(data-value) ' '; content: attr(data-value) ' ';
visibility: hidden; visibility: hidden;
white-space: nowrap; white-space: nowrap;
padding: 0 $--horiz-padding; padding: var(--spacing-3xs);
} }
&:not(.static-size)::after { &:not(.static-size)::after {

View File

@@ -37,6 +37,7 @@ onBeforeUnmount(() => {
function focus() { function focus() {
if (inputRef.value) { if (inputRef.value) {
inputRef.value.focus(); inputRef.value.focus();
inputRef.value.select();
} }
} }
@@ -67,7 +68,7 @@ function onEscape() {
<ExpandableInputBase :model-value="modelValue" :placeholder="placeholder"> <ExpandableInputBase :model-value="modelValue" :placeholder="placeholder">
<input <input
ref="inputRef" ref="inputRef"
class="el-input__inner" :class="['el-input__inner', $style.input]"
:value="modelValue" :value="modelValue"
:placeholder="placeholder" :placeholder="placeholder"
:maxlength="maxlength" :maxlength="maxlength"
@@ -78,3 +79,10 @@ function onEscape() {
/> />
</ExpandableInputBase> </ExpandableInputBase>
</template> </template>
<style module lang="scss">
.input {
padding: var(--spacing-4xs);
font-size: var(--font-size-s);
}
</style>

View File

@@ -10,18 +10,18 @@ defineProps<Props>();
<template> <template>
<ExpandableInputBase :model-value="modelValue" :static-size="true"> <ExpandableInputBase :model-value="modelValue" :static-size="true">
<input <input class="clickable preview" :value="modelValue" :disabled="true" size="4" />
:class="{ 'el-input__inner': true, clickable: true }"
:value="modelValue"
:disabled="true"
size="4"
/>
</ExpandableInputBase> </ExpandableInputBase>
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
input, input.preview {
input:hover { padding: var(--spacing-4xs);
border-radius: var(--border-radius-base);
}
.preview,
.preview:hover {
background-color: unset; background-color: unset;
transition: unset; transition: unset;
pointer-events: none; // fix firefox bug pointer-events: none; // fix firefox bug

View File

@@ -0,0 +1,126 @@
import { createComponentRenderer } from '@/__tests__/render';
import type { FolderShortInfo, UserAction } from '@/Interface';
import FolderBreadcrumbs from './FolderBreadcrumbs.vue';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useRoute } from 'vue-router';
import type { Mock } from 'vitest';
import { mockedStore } from '@/__tests__/utils';
import { useProjectsStore } from '@/stores/projects.store';
import { ProjectTypes, type Project } from '@/types/projects.types';
import { useFoldersStore } from '@/stores/folders.store';
vi.mock('vue-router', async (importOriginal) => ({
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
...(await importOriginal<typeof import('vue-router')>()),
useRoute: vi.fn().mockReturnValue({}),
useRouter: vi.fn(() => ({
replace: vi.fn(),
})),
}));
const TEST_PROJECT: Project = {
id: '1',
name: 'Test Project',
icon: { type: 'icon', value: 'folder' },
type: ProjectTypes.Personal,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
relations: [],
scopes: [],
};
const TEST_FOLDER: FolderShortInfo = {
id: '1',
name: 'Test Folder',
};
const TEST_FOLDER_CHILD: FolderShortInfo = {
id: '2',
name: 'Test Folder Child',
parentFolder: TEST_FOLDER.id,
};
const TEST_ACTIONS: UserAction[] = [
{ label: 'Action 1', value: 'action1', disabled: false },
{ label: 'Action 2', value: 'action2', disabled: true },
];
const renderComponent = createComponentRenderer(FolderBreadcrumbs, {});
describe('FolderBreadcrumbs', () => {
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let foldersStore: ReturnType<typeof mockedStore<typeof useFoldersStore>>;
beforeEach(() => {
setActivePinia(createTestingPinia());
projectsStore = mockedStore(useProjectsStore);
foldersStore = mockedStore(useFoldersStore);
(useRoute as Mock).mockReturnValue({
query: { projectId: TEST_PROJECT.id },
});
projectsStore.currentProject = TEST_PROJECT;
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render folder breadcrumbs with actions', () => {
const { getByTestId } = renderComponent({
props: {
currentFolder: TEST_FOLDER,
actions: TEST_ACTIONS,
},
});
expect(getByTestId('folder-breadcrumbs')).toBeVisible();
expect(getByTestId('home-project')).toBeVisible();
expect(getByTestId('breadcrumbs-item')).toBeVisible();
expect(getByTestId('folder-breadcrumbs-actions')).toBeVisible();
});
it('should only render project breadcrumb if currentFolder is not provided', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
currentFolder: null,
},
});
expect(getByTestId('folder-breadcrumbs')).toBeVisible();
expect(getByTestId('home-project')).toBeVisible();
expect(queryByTestId('breadcrumbs-item')).not.toBeInTheDocument();
expect(queryByTestId('folder-breadcrumbs-actions')).not.toBeInTheDocument();
});
it('should render 1 level of breadcrumbs', () => {
foldersStore.getCachedFolder.mockReturnValue(TEST_FOLDER);
const { getByTestId, queryAllByTestId } = renderComponent({
props: {
currentFolder: TEST_FOLDER_CHILD,
visibleLevels: 1,
},
});
// In this case, breadcrumbs should contain home project and current folder
// while parent is hidden by ellipsis
expect(getByTestId('folder-breadcrumbs')).toBeVisible();
expect(getByTestId('home-project')).toBeVisible();
expect(queryAllByTestId('breadcrumbs-item')).toHaveLength(1);
expect(getByTestId('ellipsis')).toBeVisible();
});
it('should render 2 levels of breadcrumbs', () => {
foldersStore.getCachedFolder.mockReturnValue(TEST_FOLDER);
const { getByTestId, queryAllByTestId, queryByTestId } = renderComponent({
props: {
currentFolder: TEST_FOLDER_CHILD,
visibleLevels: 2,
},
});
// Now, parent folder should also be visible
expect(getByTestId('folder-breadcrumbs')).toBeVisible();
expect(getByTestId('home-project')).toBeVisible();
expect(queryAllByTestId('breadcrumbs-item')).toHaveLength(2);
expect(queryByTestId('ellipsis')).not.toBeInTheDocument();
});
});

View File

@@ -1,24 +1,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { FolderPathItem } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import type { UserAction } from '@n8n/design-system/types'; import type { UserAction } from '@n8n/design-system/types';
import { type PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; import { type PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { computed } from 'vue'; import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useFoldersStore } from '@/stores/folders.store'; import { useFoldersStore } from '@/stores/folders.store';
import type { FolderPathItem, FolderShortInfo } from '@/Interface';
type Props = { type Props = {
actions: UserAction[]; // Current folder can be null when showing breadcrumbs for workflows in project root
breadcrumbs: { currentFolder?: FolderShortInfo | null;
visibleItems: FolderPathItem[]; actions?: UserAction[];
hiddenItems: FolderPathItem[];
};
hiddenItemsTrigger?: 'hover' | 'click'; hiddenItemsTrigger?: 'hover' | 'click';
currentFolderAsLink?: boolean;
visibleLevels?: 1 | 2;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
currentFolder: null,
actions: () => [],
hiddenItemsTrigger: 'click', hiddenItemsTrigger: 'click',
currentFolderAsLink: false,
visibleLevels: 1,
}); });
const emit = defineEmits<{ const emit = defineEmits<{
@@ -33,6 +37,11 @@ const i18n = useI18n();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore(); const foldersStore = useFoldersStore();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
// This will be used to filter out items that are already visible in the breadcrumbs
const visibleIds = ref<Set<string>>(new Set());
const currentProject = computed(() => projectsStore.currentProject); const currentProject = computed(() => projectsStore.currentProject);
const projectName = computed(() => { const projectName = computed(() => {
@@ -46,6 +55,64 @@ const isDragging = computed(() => {
return foldersStore.draggedElement !== null; return foldersStore.draggedElement !== null;
}); });
const hasMoreItems = computed(() => {
return visibleBreadcrumbsItems.value[0]?.parentFolder !== undefined;
});
const visibleBreadcrumbsItems = computed<FolderPathItem[]>(() => {
visibleIds.value.clear();
if (!props.currentFolder) return [];
const items: FolderPathItem[] = [];
// Only show parent folder if we are showing 2 levels of visible breadcrumbs
const parent =
props.visibleLevels === 2
? foldersStore.getCachedFolder(props.currentFolder.parentFolder ?? '')
: null;
if (parent) {
items.push({
id: parent.id,
label: parent.name,
href: `/projects/${projectsStore.currentProjectId}/folders/${parent.id}/workflows`,
parentFolder: parent.parentFolder,
});
visibleIds.value.add(parent.id);
}
items.push({
id: props.currentFolder.id,
label: props.currentFolder.name,
parentFolder: props.currentFolder.parentFolder,
href: props.currentFolderAsLink
? `/projects/${projectsStore.currentProjectId}/folders/${props.currentFolder.id}/workflows`
: undefined,
});
if (projectsStore.currentProjectId) {
visibleIds.value.add(projectsStore.currentProjectId);
}
visibleIds.value.add(props.currentFolder.id);
return items;
});
const fetchHiddenBreadCrumbsItems = async () => {
if (!projectName.value || !props.currentFolder?.parentFolder || !projectsStore.currentProjectId) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
} else {
try {
const loadedItems = foldersStore.getHiddenBreadcrumbsItems(
{ id: projectsStore.currentProjectId, name: projectName.value },
props.currentFolder.parentFolder,
{ addLinks: true },
);
const filtered = (await loadedItems).filter((item) => !visibleIds.value.has(item.id));
hiddenBreadcrumbsItemsAsync.value = Promise.resolve(filtered);
} catch (error) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
}
}
};
const onItemSelect = (item: PathItem) => { const onItemSelect = (item: PathItem) => {
emit('itemSelected', item); emit('itemSelected', item);
}; };
@@ -54,8 +121,8 @@ const onAction = (action: string) => {
emit('action', action); emit('action', action);
}; };
const onProjectMouseUp = () => { const onProjectDrop = () => {
if (!isDragging.value || !currentProject.value?.name) { if (!currentProject.value?.name) {
return; return;
} }
emit('projectDrop', currentProject.value.id, currentProject.value.name); emit('projectDrop', currentProject.value.id, currentProject.value.name);
@@ -82,6 +149,22 @@ const onItemHover = (item: PathItem) => {
name: item.label, name: item.label,
}; };
}; };
// Watch for changes in the current folder to fetch hidden breadcrumbs items
watch(
() => props.currentFolder?.parentFolder,
() => {
// Updating the promise will invalidate breadcrumbs component internal cache
hiddenBreadcrumbsItemsAsync.value = new Promise(() => {});
},
{ immediate: true },
);
// Resolve the promise to an empty array when the component is unmounted
// to avoid having dangling promises
onBeforeUnmount(() => {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
});
</script> </script>
<template> <template>
<div <div
@@ -89,33 +172,45 @@ const onItemHover = (item: PathItem) => {
data-test-id="folder-breadcrumbs" data-test-id="folder-breadcrumbs"
> >
<n8n-breadcrumbs <n8n-breadcrumbs
v-if="breadcrumbs.visibleItems" v-if="visibleBreadcrumbsItems.length"
v-model:drag-active="isDragging" v-model:drag-active="isDragging"
:items="breadcrumbs.visibleItems" :items="visibleBreadcrumbsItems"
:highlight-last-item="false" :highlight-last-item="false"
:path-truncated="breadcrumbs.visibleItems[0].parentFolder" :path-truncated="hasMoreItems"
:hidden-items="breadcrumbs.hiddenItems" :hidden-items="hasMoreItems ? hiddenBreadcrumbsItemsAsync : undefined"
:hidden-items-trigger="props.hiddenItemsTrigger" :hidden-items-trigger="props.hiddenItemsTrigger"
data-test-id="folder-list-breadcrumbs" @tooltip-opened="fetchHiddenBreadCrumbsItems"
@item-selected="onItemSelect" @item-selected="onItemSelect"
@item-hover="onItemHover" @item-hover="onItemHover"
@item-drop="emit('itemDrop', $event)" @item-drop="emit('itemDrop', $event)"
> >
<template v-if="currentProject" #prepend> <template v-if="currentProject" #prepend>
<div <ProjectBreadcrumb
:class="{ [$style['home-project']]: true, [$style.dragging]: isDragging }" :current-project="currentProject"
data-test-id="home-project" :is-dragging="isDragging"
@mouseenter="onProjectHover" @project-drop="onProjectDrop"
@mouseup="isDragging ? onProjectMouseUp() : null" @project-hover="onProjectHover"
> />
<n8n-link :to="`/projects/${currentProject.id}`"> </template>
<N8nText size="medium" color="text-base">{{ projectName }}</N8nText> <template #append>
</n8n-link> <slot name="append"></slot>
</div>
</template> </template>
</n8n-breadcrumbs> </n8n-breadcrumbs>
<!-- If there is no current folder, just show project badge -->
<div v-else-if="currentProject" :class="$style['home-project']">
<ProjectBreadcrumb
:current-project="currentProject"
:is-dragging="isDragging"
@project-drop="onProjectDrop"
@project-hover="onProjectHover"
/>
<slot name="append"></slot>
</div>
<div v-else>
<slot name="append"></slot>
</div>
<n8n-action-toggle <n8n-action-toggle
v-if="breadcrumbs.visibleItems" v-if="visibleBreadcrumbsItems && actions?.length"
:actions="actions" :actions="actions"
:class="$style['action-toggle']" :class="$style['action-toggle']"
theme="dark" theme="dark"
@@ -131,30 +226,14 @@ const onItemHover = (item: PathItem) => {
align-items: center; align-items: center;
} }
.home-project {
display: flex;
align-items: center;
}
.action-toggle { .action-toggle {
span[role='button'] { span[role='button'] {
color: var(--color-text-base); color: var(--color-text-base);
} }
} }
.home-project {
display: flex;
align-items: center;
padding: var(--spacing-4xs);
border: var(--border-width-base) var(--border-style-base) transparent;
&.dragging:hover {
border: var(--border-width-base) var(--border-style-base) var(--color-secondary);
border-radius: var(--border-radius-base);
background-color: var(--color-callout-secondary-background);
* {
cursor: grabbing;
color: var(--color-text-base);
}
}
&:hover * {
color: var(--color-text-dark);
}
}
</style> </style>

View File

@@ -0,0 +1,126 @@
import { createComponentRenderer } from '@/__tests__/render';
import { fireEvent } from '@testing-library/vue';
import ProjectBreadcrumb from './ProjectBreadcrumb.vue';
import { ProjectTypes } from '@/types/projects.types';
import type { Project } from '@vue-flow/core';
vi.mock('@/composables/useI18n', () => ({
useI18n: () => ({
baseText: vi.fn((key) => {
if (key === 'projects.menu.personal') return 'Personal';
return key;
}),
}),
}));
const mockComponents = {
'n8n-link': {
template: '<a :href="to"><slot /></a>',
props: ['to'],
},
ProjectIcon: {
template:
'<div class="project-icon" data-test-id="project-icon" :data-icon="icon.value"><slot /></div>',
props: ['icon', 'borderLess', 'size', 'title'],
},
N8nText: {
template: '<span class="n8n-text" data-test-id="project-label"><slot /></span>',
props: ['size', 'color'],
},
};
const renderComponent = createComponentRenderer(ProjectBreadcrumb, {
global: {
stubs: mockComponents,
},
});
describe('ProjectBreadcrumb', () => {
it('Renders personal project info correctly', () => {
const { getByTestId } = renderComponent({
props: {
currentProject: {
id: '1',
name: 'Test Project',
type: ProjectTypes.Personal,
} satisfies Partial<Project>,
},
});
expect(getByTestId('project-icon')).toHaveAttribute('data-icon', 'user');
expect(getByTestId('project-label')).toHaveTextContent('Personal');
});
it('Renders team project info correctly', () => {
const { getByTestId } = renderComponent({
props: {
currentProject: {
id: '1',
name: 'Team Project',
type: ProjectTypes.Team,
icon: { type: 'icon', value: 'folder' },
} satisfies Partial<Project>,
},
});
expect(getByTestId('project-icon')).toHaveAttribute('data-icon', 'folder');
expect(getByTestId('project-label')).toHaveTextContent('Team Project');
});
it('Renders team project emoji icon correctly', () => {
const { getByTestId } = renderComponent({
props: {
currentProject: {
id: '1',
name: 'Team Project',
type: ProjectTypes.Team,
icon: { type: 'emoji', value: '🔥' },
} satisfies Partial<Project>,
},
});
expect(getByTestId('project-icon')).toHaveAttribute('data-icon', '🔥');
expect(getByTestId('project-label')).toHaveTextContent('Team Project');
});
it('emits hover event', async () => {
const { emitted, getByTestId } = renderComponent({
props: {
currentProject: {
id: '1',
name: 'Test Project',
type: ProjectTypes.Personal,
} satisfies Partial<Project>,
},
});
await fireEvent.mouseEnter(getByTestId('home-project'));
expect(emitted('projectHover')).toBeTruthy();
});
it('emits project drop event if dragging', async () => {
const { emitted, getByTestId } = renderComponent({
props: {
isDragging: true,
currentProject: {
id: '1',
name: 'Test Project',
type: ProjectTypes.Personal,
} satisfies Partial<Project>,
},
});
await fireEvent.mouseUp(getByTestId('home-project'));
expect(emitted('projectDrop')).toBeTruthy();
});
it('does not emit project drop event if not dragging', async () => {
const { emitted, getByTestId } = renderComponent({
props: {
isDragging: false,
currentProject: {
id: '1',
name: 'Test Project',
type: ProjectTypes.Personal,
} satisfies Partial<Project>,
},
});
await fireEvent.mouseUp(getByTestId('home-project'));
expect(emitted('projectDrop')).toBeFalsy();
});
});

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import {
type Project,
type ProjectIcon as ProjectIconType,
ProjectTypes,
} from '@/types/projects.types';
type Props = {
currentProject: Project;
isDragging?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
isDragging: false,
});
const emit = defineEmits<{
projectHover: [];
projectDrop: [];
}>();
const i18n = useI18n();
const projectIcon = computed((): ProjectIconType => {
if (props.currentProject?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (props.currentProject?.name) {
return props.currentProject.icon ?? { type: 'icon', value: 'layer-group' };
} else {
return { type: 'icon', value: 'home' };
}
});
const projectName = computed(() => {
if (props.currentProject.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return props.currentProject.name;
});
const onHover = () => {
emit('projectHover');
};
const onProjectMouseUp = () => {
if (props.isDragging) {
emit('projectDrop');
}
};
</script>
<template>
<div
:class="{ [$style['home-project']]: true, [$style.dragging]: isDragging }"
data-test-id="home-project"
@mouseenter="onHover"
@mouseup="isDragging ? onProjectMouseUp() : null"
>
<n8n-link :to="`/projects/${currentProject.id}`" :class="[$style['project-link']]">
<ProjectIcon :icon="projectIcon" :border-less="true" size="mini" :title="projectName" />
<N8nText size="medium" color="text-base" :class="$style['project-label']">
{{ projectName }}
</N8nText>
</n8n-link>
</div>
</template>
<style module lang="scss">
.home-project {
display: flex;
padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs);
border: var(--border-width-base) var(--border-style-base) transparent;
&.dragging:hover {
border: var(--border-width-base) var(--border-style-base) var(--color-secondary);
border-radius: var(--border-radius-base);
background-color: var(--color-callout-secondary-background);
* {
cursor: grabbing;
color: var(--color-text-base);
}
}
&:hover :global(.n8n-text) {
color: var(--color-text-dark);
}
}
.project-link :global(.n8n-text) {
display: flex;
align-items: center;
gap: var(--spacing-4xs);
}
:global(.n8n-text).project-label {
@media (max-width: $breakpoint-sm) {
display: none;
}
}
</style>

View File

@@ -40,6 +40,15 @@ watch(
}, },
); );
watch(
() => props.modelValue,
(value) => {
if (isDisabled.value) return;
newValue.value = value;
},
{ immediate: true },
);
function onInput(val: string) { function onInput(val: string) {
if (isDisabled.value) return; if (isDisabled.value) return;
newValue.value = val; newValue.value = val;
@@ -79,7 +88,7 @@ function onEscape() {
</script> </script>
<template> <template>
<span class="inline-edit" @keydown.stop> <span :class="$style['inline-edit']" @keydown.stop>
<span v-if="isEditEnabled && !isDisabled"> <span v-if="isEditEnabled && !isDisabled">
<ExpandableInputEdit <ExpandableInputEdit
v-model="newValue" v-model="newValue"
@@ -87,6 +96,7 @@ function onEscape() {
:maxlength="maxLength" :maxlength="maxLength"
:autofocus="true" :autofocus="true"
:event-bus="inputBus" :event-bus="inputBus"
data-test-id="inline-edit-input"
@update:model-value="onInput" @update:model-value="onInput"
@esc="onEscape" @esc="onEscape"
@blur="onBlur" @blur="onBlur"
@@ -94,13 +104,25 @@ function onEscape() {
/> />
</span> </span>
<span v-else class="preview" @click="onClick"> <span v-else :class="$style.preview" @click="onClick">
<ExpandableInputPreview :model-value="previewValue || modelValue" /> <ExpandableInputPreview :model-value="previewValue || modelValue" />
</span> </span>
</span> </span>
</template> </template>
<style lang="scss" scoped> <style lang="scss" module>
/* Magic numbers here but this keeps preview and this input vertically aligned */
.inline-edit {
display: block;
height: 25px;
input {
display: block;
height: 27px;
min-height: 27px;
}
}
.preview { .preview {
cursor: pointer; cursor: pointer;
} }

View File

@@ -25,6 +25,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useLocalStorage } from '@vueuse/core'; import { useLocalStorage } from '@vueuse/core';
import GithubButton from 'vue-github-button'; import GithubButton from 'vue-github-button';
import type { FolderShortInfo } from '@/Interface';
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -94,6 +95,17 @@ const showGitHubButton = computed(
() => !isEnterprise.value && !settingsStore.settings.inE2ETests && !githubButtonHidden.value, () => !isEnterprise.value && !settingsStore.settings.inE2ETests && !githubButtonHidden.value,
); );
const parentFolderForBreadcrumbs = computed<FolderShortInfo | undefined>(() => {
if (!workflow.value.parentFolder) {
return undefined;
}
return {
id: workflow.value.parentFolder.id,
name: workflow.value.parentFolder.name,
parentFolder: workflow.value.parentFolder.parentFolderId ?? undefined,
};
});
watch(route, (to, from) => { watch(route, (to, from) => {
syncTabsWithRoute(to, from); syncTabsWithRoute(to, from);
}); });
@@ -238,6 +250,7 @@ function hideGithubButton() {
:scopes="workflow.scopes" :scopes="workflow.scopes"
:active="workflow.active" :active="workflow.active"
:read-only="readOnly" :read-only="readOnly"
:current-folder="parentFolderForBreadcrumbs"
/> />
<div v-if="showGitHubButton" :class="[$style['github-button'], 'hidden-sm-and-down']"> <div v-if="showGitHubButton" :class="[$style['github-button'], 'hidden-sm-and-down']">
<div :class="$style['github-button-container']"> <div :class="$style['github-button-container']">
@@ -288,17 +301,19 @@ function hideGithubButton() {
.top-menu { .top-menu {
position: relative; position: relative;
display: flex; display: flex;
height: var(--navbar--height);
align-items: center; align-items: center;
font-size: 0.9em; font-size: 0.9em;
font-weight: var(--font-weight-regular); font-weight: var(--font-weight-regular);
overflow: auto; overflow-x: auto;
overflow-y: hidden;
} }
.github-button { .github-button {
display: flex; display: flex;
align-items: center; align-items: center;
align-self: stretch; align-self: stretch;
padding: var(--spacing-5xs) var(--spacing-m) 0; padding: var(--spacing-5xs) var(--spacing-m);
background-color: var(--color-background-xlight); background-color: var(--color-background-xlight);
border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base); border-left: var(--border-width-base) var(--border-style-base) var(--color-foreground-base);
} }

View File

@@ -4,11 +4,16 @@ import { EnterpriseEditionFeature, STORES, WORKFLOW_SHARE_MODAL_KEY } from '@/co
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router';
import type { Mock } from 'vitest';
vi.mock('vue-router', () => ({ vi.mock('vue-router', async (importOriginal) => ({
useRoute: () => vi.fn(), // eslint-disable-next-line @typescript-eslint/consistent-type-imports
useRouter: () => vi.fn(), ...(await importOriginal<typeof import('vue-router')>()),
RouterLink: vi.fn(), useRoute: vi.fn().mockReturnValue({}),
useRouter: vi.fn(() => ({
replace: vi.fn(),
})),
})); }));
vi.mock('@/stores/pushConnection.store', () => ({ vi.mock('@/stores/pushConnection.store', () => ({
@@ -46,6 +51,9 @@ const renderComponent = createComponentRenderer(WorkflowDetails, {
global: { global: {
stubs: { stubs: {
RouterLink: true, RouterLink: true,
FolderBreadcrumbs: {
template: '<div><slot name="append" /></div>',
},
}, },
}, },
}); });
@@ -63,6 +71,9 @@ describe('WorkflowDetails', () => {
uiStore = useUIStore(); uiStore = useUIStore();
}); });
it('renders workflow name and tags', async () => { it('renders workflow name and tags', async () => {
(useRoute as Mock).mockReturnValue({
query: { parentFolderId: '1' },
});
const { getByTestId, getByText } = renderComponent({ const { getByTestId, getByText } = renderComponent({
props: { props: {
...workflow, ...workflow,

View File

@@ -46,6 +46,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { computed, ref, useCssModule, watch } from 'vue'; import { computed, ref, useCssModule, watch } from 'vue';
import type { import type {
ActionDropdownItem, ActionDropdownItem,
FolderShortInfo,
IWorkflowDataUpdate, IWorkflowDataUpdate,
IWorkflowDb, IWorkflowDb,
IWorkflowToShare, IWorkflowToShare,
@@ -56,6 +57,8 @@ import type { BaseTextKey } from '@/plugins/i18n';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store'; import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import { useFoldersStore } from '@/stores/folders.store';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
const props = defineProps<{ const props = defineProps<{
readOnly?: boolean; readOnly?: boolean;
@@ -65,6 +68,7 @@ const props = defineProps<{
meta: IWorkflowDb['meta']; meta: IWorkflowDb['meta'];
scopes: IWorkflowDb['scopes']; scopes: IWorkflowDb['scopes'];
active: IWorkflowDb['active']; active: IWorkflowDb['active'];
currentFolder?: FolderShortInfo;
}>(); }>();
const $style = useCssModule(); const $style = useCssModule();
@@ -78,6 +82,7 @@ const uiStore = useUIStore();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const workflowsStore = useWorkflowsStore(); const workflowsStore = useWorkflowsStore();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore();
const npsSurveyStore = useNpsSurveyStore(); const npsSurveyStore = useNpsSurveyStore();
const i18n = useI18n(); const i18n = useI18n();
@@ -139,6 +144,11 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
label: locale.baseText('menuActions.download'), label: locale.baseText('menuActions.download'),
disabled: !onWorkflowPage.value, disabled: !onWorkflowPage.value,
}, },
{
id: WORKFLOW_MENU_ACTIONS.RENAME,
label: locale.baseText('generic.rename'),
disabled: !onWorkflowPage.value || workflowPermissions.value.update !== true,
},
]; ];
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) { if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
@@ -201,19 +211,6 @@ const workflowTagIds = computed(() => {
return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id)); return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id));
}); });
const currentFolder = computed(() => {
if (props.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
return undefined;
}
const workflow = workflowsStore.getWorkflowById(props.id);
if (!workflow) {
return undefined;
}
return workflow.parentFolder;
});
const currentProjectName = computed(() => { const currentProjectName = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) { if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return locale.baseText('projects.menu.personal'); return locale.baseText('projects.menu.personal');
@@ -221,6 +218,18 @@ const currentProjectName = computed(() => {
return projectsStore.currentProject?.name; return projectsStore.currentProject?.name;
}); });
const currentFolderForBreadcrumbs = computed(() => {
if (!isNewWorkflow.value && props.currentFolder) {
return props.currentFolder;
}
const folderId = route.query.parentFolderId as string;
if (folderId) {
return foldersStore.getCachedFolder(folderId);
}
return null;
});
watch( watch(
() => props.id, () => props.id,
() => { () => {
@@ -345,8 +354,8 @@ async function onNameSubmit({
const newName = name.trim(); const newName = name.trim();
if (!newName) { if (!newName) {
toast.showMessage({ toast.showMessage({
title: locale.baseText('workflowDetails.showMessage.title'), title: locale.baseText('renameAction.emptyName.title'),
message: locale.baseText('workflowDetails.showMessage.message'), message: locale.baseText('renameAction.emptyName.message'),
type: 'error', type: 'error',
}); });
@@ -408,11 +417,15 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
id: props.id, id: props.id,
name: props.name, name: props.name,
tags: props.tags, tags: props.tags,
parentFolderId: currentFolder?.value?.id, parentFolderId: props.currentFolder?.id,
}, },
}); });
break; break;
} }
case WORKFLOW_MENU_ACTIONS.RENAME: {
onNameToggle();
break;
}
case WORKFLOW_MENU_ACTIONS.DOWNLOAD: { case WORKFLOW_MENU_ACTIONS.DOWNLOAD: {
const workflowData = await workflowHelpers.getWorkflowDataToSave(); const workflowData = await workflowHelpers.getWorkflowDataToSave();
const { tags, ...data } = workflowData; const { tags, ...data } = workflowData;
@@ -556,11 +569,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
let toastText = locale.baseText('workflows.create.personal.toast.text'); let toastText = locale.baseText('workflows.create.personal.toast.text');
if (projectsStore.currentProject) { if (projectsStore.currentProject) {
if (currentFolder.value) { if (props.currentFolder) {
toastTitle = locale.baseText('workflows.create.folder.toast.title', { toastTitle = locale.baseText('workflows.create.folder.toast.title', {
interpolate: { interpolate: {
projectName: currentProjectName.value ?? '', projectName: currentProjectName.value ?? '',
folderName: currentFolder.value.name ?? '', folderName: props.currentFolder.name ?? '',
}, },
}); });
} else if (projectsStore.currentProject.id !== projectsStore.personalProject?.id) { } else if (projectsStore.currentProject.id !== projectsStore.personalProject?.id) {
@@ -581,27 +594,50 @@ function showCreateWorkflowSuccessToast(id?: string) {
}); });
} }
} }
const onBreadcrumbsItemSelected = (item: PathItem) => {
if (item.href) {
void router.push(item.href).catch((error) => {
toast.showError(error, i18n.baseText('folders.open.error.title'));
});
}
};
</script> </script>
<template> <template>
<div :class="$style.container"> <div :class="$style.container">
<BreakpointsObserver :value-x-s="15" :value-s-m="25" :value-m-d="50" class="name-container"> <BreakpointsObserver
:value-x-s="15"
:value-s-m="25"
:value-m-d="50"
class="name-container"
data-test-id="canvas-breadcrumbs"
>
<template #default="{ value }"> <template #default="{ value }">
<ShortenName :name="name" :limit="value" :custom="true" test-id="workflow-name-input"> <FolderBreadcrumbs
<template #default="{ shortenedName }"> :current-folder="currentFolderForBreadcrumbs"
<InlineTextEdit :current-folder-as-link="true"
:model-value="name" @item-selected="onBreadcrumbsItemSelected"
:preview-value="shortenedName" >
:is-edit-enabled="isNameEditEnabled" <template #append>
:max-length="MAX_WORKFLOW_NAME_LENGTH" <span v-if="projectsStore.currentProject" :class="$style['path-separator']">/</span>
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)" <ShortenName :name="name" :limit="value" :custom="true" test-id="workflow-name-input">
placeholder="Enter workflow name" <template #default="{ shortenedName }">
class="name" <InlineTextEdit
@toggle="onNameToggle" :model-value="name"
@submit="onNameSubmit" :preview-value="shortenedName"
/> :is-edit-enabled="isNameEditEnabled"
:max-length="MAX_WORKFLOW_NAME_LENGTH"
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)"
placeholder="Enter workflow name"
class="name"
@toggle="onNameToggle"
@submit="onNameSubmit"
/>
</template>
</ShortenName>
</template> </template>
</ShortenName> </FolderBreadcrumbs>
</template> </template>
</BreakpointsObserver> </BreakpointsObserver>
@@ -728,7 +764,7 @@ $--text-line-height: 24px;
$--header-spacing: 20px; $--header-spacing: 20px;
.name-container { .name-container {
margin-right: $--header-spacing; margin-right: var(--spacing-s);
:deep(.el-input) { :deep(.el-input) {
padding: 0; padding: 0;
@@ -737,8 +773,7 @@ $--header-spacing: 20px;
.name { .name {
color: $custom-font-dark; color: $custom-font-dark;
font-size: 15px; font-size: var(--font-size-s);
display: block;
} }
.activator { .activator {
@@ -805,6 +840,12 @@ $--header-spacing: 20px;
flex-wrap: nowrap; flex-wrap: nowrap;
} }
.path-separator {
font-size: var(--font-size-xl);
color: var(--color-foreground-base);
margin: var(--spacing-4xs);
}
.group { .group {
display: flex; display: flex;
gap: var(--spacing-xs); gap: var(--spacing-xs);

View File

@@ -14,7 +14,7 @@ import { useRoute, useRouter } from 'vue-router';
import type { BaseTextKey } from '@/plugins/i18n'; import type { BaseTextKey } from '@/plugins/i18n';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import type { BaseFolderItem, BaseResource, FolderShortInfo, ITag } from '@/Interface'; import type { BaseFolderItem, BaseResource, ITag, ResourceParentFolder } from '@/Interface';
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards'; import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders'; type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
@@ -34,7 +34,7 @@ export type WorkflowResource = BaseResource & {
tags?: ITag[] | string[]; tags?: ITag[] | string[];
sharedWithProjects?: ProjectSharingData[]; sharedWithProjects?: ProjectSharingData[];
readOnly: boolean; readOnly: boolean;
parentFolder?: FolderShortInfo; parentFolder?: ResourceParentFolder;
}; };
export type VariableResource = BaseResource & { export type VariableResource = BaseResource & {

View File

@@ -133,12 +133,8 @@ export function useFolders() {
} }
/** /**
* Get the drag or drop target element from the event target and store * Handle the drop event by getting the dragged resource and drop target
* @param event * @param event
* @returns {
* draggedResource: DragTarget | undefined;
* dropTarget: DropTarget | undefined;
* }
*/ */
function handleDrop(event: MouseEvent): { function handleDrop(event: MouseEvent): {
draggedResource?: DragTarget; draggedResource?: DragTarget;

View File

@@ -613,6 +613,7 @@ export const enum WORKFLOW_MENU_ACTIONS {
SETTINGS = 'settings', SETTINGS = 'settings',
DELETE = 'delete', DELETE = 'delete',
SWITCH_NODE_VIEW_VERSION = 'switch-node-view-version', SWITCH_NODE_VIEW_VERSION = 'switch-node-view-version',
RENAME = 'rename',
} }
/** /**

View File

@@ -945,6 +945,7 @@
"folders.rename.message": "Rename \"{folderName}\"", "folders.rename.message": "Rename \"{folderName}\"",
"folders.rename.error.title": "Problem renaming folder", "folders.rename.error.title": "Problem renaming folder",
"folders.rename.success.message": "Folder renamed", "folders.rename.success.message": "Folder renamed",
"folders.rename.placeholder": "Enter new folder name",
"folders.not.found.message": "Folder not found", "folders.not.found.message": "Folder not found",
"folders.move.modal.title": "Move \"{folderName}\" to another folder", "folders.move.modal.title": "Move \"{folderName}\" to another folder",
"folders.move.modal.description": "Note: Moving this folder will also move all workflows and subfolders within it.", "folders.move.modal.description": "Note: Moving this folder will also move all workflows and subfolders within it.",
@@ -1679,6 +1680,9 @@
"prompts.npsSurvey.yourEmailAddress": "Your email address", "prompts.npsSurvey.yourEmailAddress": "Your email address",
"prompts.npsSurvey.reviewUs": "If youd like to help even more, leave us a <a target=\"_blank\" href=\"https://www.g2.com/products/n8n/reviews/start\">review on G2</a>.", "prompts.npsSurvey.reviewUs": "If youd like to help even more, leave us a <a target=\"_blank\" href=\"https://www.g2.com/products/n8n/reviews/start\">review on G2</a>.",
"prompts.npsSurvey.thanks": "Thanks for your feedback", "prompts.npsSurvey.thanks": "Thanks for your feedback",
"renameAction.emptyName.message": "Please enter a name, or press 'esc' to go back to the old one",
"renameAction.emptyName.title": "Name missing",
"renameAction.invalidName.title": "Invalid name",
"resourceLocator.id.placeholder": "Enter ID...", "resourceLocator.id.placeholder": "Enter ID...",
"resourceLocator.mode.id": "By ID", "resourceLocator.mode.id": "By ID",
"resourceLocator.mode.url": "By URL", "resourceLocator.mode.url": "By URL",
@@ -2321,8 +2325,6 @@
"workflowDetails.active": "Active", "workflowDetails.active": "Active",
"workflowDetails.addTag": "Add tag", "workflowDetails.addTag": "Add tag",
"workflowDetails.chooseOrCreateATag": "Choose or create a tag", "workflowDetails.chooseOrCreateATag": "Choose or create a tag",
"workflowDetails.showMessage.message": "Please enter a name, or press 'esc' to go back to the old one",
"workflowDetails.showMessage.title": "Name missing",
"workflowHelpers.showMessage.title": "Problem saving workflow", "workflowHelpers.showMessage.title": "Problem saving workflow",
"workflowOpen.active": "Active", "workflowOpen.active": "Active",
"workflowOpen.couldNotLoadActiveWorkflows": "Could not load active workflows", "workflowOpen.couldNotLoadActiveWorkflows": "Could not load active workflows",

View File

@@ -11,6 +11,7 @@ import { useRootStore } from './root.store';
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import type { DragTarget, DropTarget } from '@/composables/useFolders'; import type { DragTarget, DropTarget } from '@/composables/useFolders';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
const BREADCRUMBS_MIN_LOADING_TIME = 300; const BREADCRUMBS_MIN_LOADING_TIME = 300;
@@ -182,18 +183,22 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
async function getHiddenBreadcrumbsItems( async function getHiddenBreadcrumbsItems(
project: { id: string; name: string }, project: { id: string; name: string },
folderId: string, folderId: string,
options?: {
addLinks?: boolean;
},
) { ) {
const startTime = Date.now(); const startTime = Date.now();
const path = await getFolderPath(project.id, folderId); const path = await getFolderPath(project.id, folderId);
// Process a folder and all its nested children recursively // Process a folder and all its nested children recursively
const processFolderWithChildren = ( const processFolderWithChildren = (folder: FolderTreeResponseItem): PathItem[] => {
folder: FolderTreeResponseItem, const result: PathItem[] = [
): Array<{ id: string; label: string }> => {
const result = [
{ {
id: folder.id, id: folder.id,
label: folder.name, label: folder.name,
href: options?.addLinks
? `/projects/${project.id}/folders/${folder.id}/workflows`
: undefined,
}, },
]; ];
@@ -201,10 +206,13 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
if (folder.children?.length) { if (folder.children?.length) {
const childItems = folder.children.flatMap((child) => { const childItems = folder.children.flatMap((child) => {
// Add this child // Add this child
const childResult = [ const childResult: PathItem[] = [
{ {
id: child.id, id: child.id,
label: child.name, label: child.name,
href: options?.addLinks
? `/projects/${project.id}/folders/${child.id}/workflows`
: undefined,
}, },
]; ];

View File

@@ -148,9 +148,19 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const setProjectNavActiveIdByWorkflowHomeProject = async ( const setProjectNavActiveIdByWorkflowHomeProject = async (
homeProject?: IWorkflowDb['homeProject'], homeProject?: IWorkflowDb['homeProject'],
) => { ) => {
// Handle personal projects
if (homeProject?.type === ProjectTypes.Personal) { if (homeProject?.type === ProjectTypes.Personal) {
projectNavActiveId.value = 'home'; const isOwnPersonalProject = personalProject.value?.id === homeProject?.id;
// If it's current user's personal project, set it as current project
if (isOwnPersonalProject) {
projectNavActiveId.value = homeProject?.id ?? null;
currentProject.value = personalProject.value;
} else {
// Else default to overview page
projectNavActiveId.value = 'home';
}
} else { } else {
// Handle team projects
projectNavActiveId.value = homeProject?.id ?? null; projectNavActiveId.value = homeProject?.id ?? null;
if (homeProject?.id && !currentProjectId.value) { if (homeProject?.id && !currentProjectId.value) {
await getProject(homeProject?.id); await getProject(homeProject?.id);

View File

@@ -1138,6 +1138,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
} }
} }
function setParentFolder(folder: IWorkflowDb['parentFolder']) {
workflow.value = {
...workflow.value,
parentFolder: folder,
};
}
function setNodes(nodes: INodeUi[]): void { function setNodes(nodes: INodeUi[]): void {
workflow.value.nodes = nodes; workflow.value.nodes = nodes;
nodes.forEach((node) => { nodes.forEach((node) => {
@@ -1843,6 +1850,7 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
setWorkflowSettings, setWorkflowSettings,
setWorkflowPinData, setWorkflowPinData,
setWorkflowTagIds, setWorkflowTagIds,
setParentFolder,
addWorkflowTagIds, addWorkflowTagIds,
removeWorkflowTagId, removeWorkflowTagId,
setWorkflowScopes, setWorkflowScopes,

View File

@@ -114,6 +114,7 @@ import { getEasyAiWorkflowJson } from '@/utils/easyAiWorkflowUtils';
import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout'; import type { CanvasLayoutEvent } from '@/composables/useCanvasLayout';
import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible'; import { useClearExecutionButtonVisible } from '@/composables/useClearExecutionButtonVisible';
import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs'; import { LOGS_PANEL_STATE } from '@/components/CanvasChat/types/logs';
import { useFoldersStore } from '@/stores/folders.store';
defineOptions({ defineOptions({
name: 'NodeView', name: 'NodeView',
@@ -164,6 +165,7 @@ const tagsStore = useTagsStore();
const pushConnectionStore = usePushConnectionStore(); const pushConnectionStore = usePushConnectionStore();
const ndvStore = useNDVStore(); const ndvStore = useNDVStore();
const templatesStore = useTemplatesStore(); const templatesStore = useTemplatesStore();
const foldersStore = useFoldersStore();
const canvasEventBus = createEventBus<CanvasEventBusEvents>(); const canvasEventBus = createEventBus<CanvasEventBusEvents>();
@@ -382,23 +384,59 @@ async function initializeRoute(force = false) {
async function initializeWorkspaceForNewWorkflow() { async function initializeWorkspaceForNewWorkflow() {
resetWorkspace(); resetWorkspace();
const parentFolderId = route.query.parentFolderId as string | undefined;
await workflowsStore.getNewWorkflowData( await workflowsStore.getNewWorkflowData(
undefined, undefined,
projectsStore.currentProjectId, projectsStore.currentProjectId,
route.query.parentFolderId as string | undefined, parentFolderId,
); );
workflowsStore.makeNewWorkflowShareable(); workflowsStore.makeNewWorkflowShareable();
if (projectsStore.currentProjectId) {
await fetchAndSetProject(projectsStore.currentProjectId);
}
await fetchAndSetParentFolder(parentFolderId);
uiStore.nodeViewInitialized = true; uiStore.nodeViewInitialized = true;
initializedWorkflowId.value = NEW_WORKFLOW_ID; initializedWorkflowId.value = NEW_WORKFLOW_ID;
} }
// These two methods load home project and parent folder data if they are not already loaded
// This happens when user lands straight on the new workflow page and we have nothing in the store
async function fetchAndSetParentFolder(folderId?: string) {
if (folderId) {
let parentFolder = foldersStore.getCachedFolder(folderId);
if (!parentFolder && projectsStore.currentProjectId) {
await foldersStore.getFolderPath(projectsStore.currentProjectId, folderId);
parentFolder = foldersStore.getCachedFolder(folderId);
}
if (parentFolder) {
workflowsStore.setParentFolder({
...parentFolder,
parentFolderId: parentFolder.parentFolder ?? null,
});
}
}
}
async function fetchAndSetProject(projectId: string) {
if (!projectsStore.currentProject) {
const project = await projectsStore.fetchProject(projectId);
projectsStore.setCurrentProject(project);
}
}
async function initializeWorkspaceForExistingWorkflow(id: string) { async function initializeWorkspaceForExistingWorkflow(id: string) {
try { try {
const workflowData = await workflowsStore.fetchWorkflow(id); const workflowData = await workflowsStore.fetchWorkflow(id);
openWorkflow(workflowData); openWorkflow(workflowData);
if (workflowData.parentFolder) {
workflowsStore.setParentFolder(workflowData.parentFolder);
}
if (workflowData.meta?.onboardingId) { if (workflowData.meta?.onboardingId) {
trackOpenWorkflowFromOnboardingTemplate(); trackOpenWorkflowFromOnboardingTemplate();
} }

View File

@@ -32,7 +32,6 @@ import InsightsSummary from '@/features/insights/components/InsightsSummary.vue'
import { useInsightsStore } from '@/features/insights/insights.store'; import { useInsightsStore } from '@/features/insights/insights.store';
import type { import type {
FolderListItem, FolderListItem,
FolderPathItem,
IUser, IUser,
UserAction, UserAction,
WorkflowListItem, WorkflowListItem,
@@ -136,6 +135,8 @@ const currentFolderId = ref<string | null>(null);
const showCardsBadge = ref(false); const showCardsBadge = ref(false);
const isNameEditEnabled = ref(false);
/** /**
* Folder actions * Folder actions
* These can appear on the list header, and then they are applied to current folder * These can appear on the list header, and then they are applied to current folder
@@ -213,6 +214,12 @@ const currentFolder = computed(() => {
return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null; return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null;
}); });
const currentFolderParent = computed(() => {
return currentFolder.value?.parentFolder
? foldersStore.breadcrumbsCache[currentFolder.value.parentFolder]
: null;
});
const isDragging = computed(() => { const isDragging = computed(() => {
return foldersStore.draggedElement !== null; return foldersStore.draggedElement !== null;
}); });
@@ -504,6 +511,7 @@ const fetchWorkflows = async () => {
.map((r) => ({ id: r.id, name: r.name, parentFolder: r.parentFolder?.id })), .map((r) => ({ id: r.id, name: r.name, parentFolder: r.parentFolder?.id })),
); );
// This is for the case when user lands straight on a folder page
const isCurrentFolderCached = foldersStore.breadcrumbsCache[parentFolder ?? ''] !== undefined; const isCurrentFolderCached = foldersStore.breadcrumbsCache[parentFolder ?? ''] !== undefined;
const needToFetchFolderPath = parentFolder && !isCurrentFolderCached && routeProjectId; const needToFetchFolderPath = parentFolder && !isCurrentFolderCached && routeProjectId;
@@ -857,63 +865,6 @@ const moveResourceOnDrop = async (draggedResource: DragTarget, dropTarget: DropT
// Breadcrumbs methods // Breadcrumbs methods
/**
* Breadcrumbs: Calculate visible and hidden items for both main breadcrumbs and card breadcrumbs
* We do this here and pass to each component to avoid recalculating in each card
*/
const visibleBreadcrumbsItems = computed<FolderPathItem[]>(() => {
if (!currentFolder.value) return [];
const items: FolderPathItem[] = [];
const parent = foldersStore.getCachedFolder(currentFolder.value.parentFolder ?? '');
if (parent) {
items.push({
id: parent.id,
label: parent.name,
href: `/projects/${route.params.projectId}/folders/${parent.id}/workflows`,
parentFolder: parent.parentFolder,
});
}
items.push({
id: currentFolder.value.id,
label: currentFolder.value.name,
parentFolder: parent?.parentFolder,
});
return items;
});
const hiddenBreadcrumbsItems = computed<FolderPathItem[]>(() => {
const lastVisibleParent: FolderPathItem =
visibleBreadcrumbsItems.value[visibleBreadcrumbsItems.value.length - 1];
if (!lastVisibleParent) return [];
const items: FolderPathItem[] = [];
// Go through all the parent folders and add them to the hidden items
let parentFolder = lastVisibleParent.parentFolder;
while (parentFolder) {
const parent = foldersStore.getCachedFolder(parentFolder);
if (!parent) break;
items.unshift({
id: parent.id,
label: parent.name,
href: `/projects/${route.params.projectId}/folders/${parent.id}/workflows`,
parentFolder: parent.parentFolder,
});
parentFolder = parent.parentFolder;
}
return items;
});
/**
* Main breadcrumbs items that show on top of the list
* These show path to the current folder with up to 2 parents visible
*/
const mainBreadcrumbs = computed(() => {
return {
visibleItems: visibleBreadcrumbsItems.value,
hiddenItems: hiddenBreadcrumbsItems.value,
};
});
const onBreadcrumbItemClick = (item: PathItem) => { const onBreadcrumbItemClick = (item: PathItem) => {
if (item.href) { if (item.href) {
loading.value = true; loading.value = true;
@@ -958,8 +909,7 @@ const onBreadCrumbsAction = async (action: string) => {
); );
break; break;
case FOLDER_LIST_ITEM_ACTIONS.RENAME: case FOLDER_LIST_ITEM_ACTIONS.RENAME:
if (!route.params.folderId) return; onNameToggle();
await renameFolder(route.params.folderId as string);
break; break;
case FOLDER_LIST_ITEM_ACTIONS.MOVE: case FOLDER_LIST_ITEM_ACTIONS.MOVE:
if (!currentFolder.value) return; if (!currentFolder.value) return;
@@ -1307,6 +1257,69 @@ const onCreateWorkflowClick = () => {
}, },
}); });
}; };
const onNameToggle = () => {
isNameEditEnabled.value = !isNameEditEnabled.value;
};
const onNameSubmit = async ({
name,
onSubmit,
}: {
name: string;
onSubmit: (saved: boolean) => void;
}) => {
if (!currentFolder.value || !currentProject.value) return;
const newName = name.trim();
if (!newName) {
toast.showMessage({
title: i18n.baseText('renameAction.emptyName.title'),
message: i18n.baseText('renameAction.emptyName.message'),
type: 'error',
});
onSubmit(false);
return;
}
if (newName === currentFolder.value.name) {
isNameEditEnabled.value = false;
onSubmit(true);
return;
}
const validationResult = folderHelpers.validateFolderName(newName);
if (typeof validationResult === 'string') {
toast.showMessage({
title: i18n.baseText('renameAction.invalidName.title'),
message: validationResult,
type: 'error',
});
onSubmit(false);
return;
} else {
try {
await foldersStore.renameFolder(currentProject.value?.id, currentFolder.value.id, newName);
foldersStore.breadcrumbsCache[currentFolder.value.id].name = newName;
toast.showMessage({
title: i18n.baseText('folders.rename.success.message', {
interpolate: { folderName: newName },
}),
type: 'success',
});
telemetry.track('User renamed folder', {
folder_id: currentFolder.value.id,
});
isNameEditEnabled.value = false;
onSubmit(true);
} catch (error) {
toast.showError(error, i18n.baseText('folders.rename.error.title'));
onSubmit(false);
}
}
};
</script> </script>
<template> <template>
@@ -1417,14 +1430,31 @@ const onCreateWorkflowClick = () => {
data-test-id="main-breadcrumbs" data-test-id="main-breadcrumbs"
> >
<FolderBreadcrumbs <FolderBreadcrumbs
:breadcrumbs="mainBreadcrumbs" :current-folder="currentFolderParent"
:actions="mainBreadcrumbsActions" :actions="mainBreadcrumbsActions"
:hidden-items-trigger="isDragging ? 'hover' : 'click'" :hidden-items-trigger="isDragging ? 'hover' : 'click'"
:current-folder-as-link="true"
@item-selected="onBreadcrumbItemClick" @item-selected="onBreadcrumbItemClick"
@action="onBreadCrumbsAction" @action="onBreadCrumbsAction"
@item-drop="onBreadCrumbsItemDrop" @item-drop="onBreadCrumbsItemDrop"
@project-drop="moveFolderToProjectRoot" @project-drop="moveFolderToProjectRoot"
/> >
<template #append>
<span :class="$style['path-separator']">/</span>
<InlineTextEdit
data-test-id="breadcrumbs-item-current"
:model-value="currentFolder.name"
:preview-value="currentFolder.name"
:is-edit-enabled="isNameEditEnabled"
:max-length="30"
:disabled="readOnlyEnv || !hasPermissionToUpdateFolders"
:class="{ [$style.name]: true, [$style['pointer-disabled']]: isDragging }"
:placeholder="i18n.baseText('folders.rename.placeholder')"
@toggle="onNameToggle"
@submit="onNameSubmit"
/>
</template>
</FolderBreadcrumbs>
</div> </div>
</template> </template>
<template #item="{ item: data, index }"> <template #item="{ item: data, index }">
@@ -1716,6 +1746,21 @@ const onCreateWorkflowClick = () => {
background-color: var(--color-callout-secondary-background); background-color: var(--color-callout-secondary-background);
} }
} }
.path-separator {
font-size: var(--font-size-xl);
color: var(--color-foreground-base);
margin: var(--spacing-4xs);
}
.name {
color: $custom-font-dark;
font-size: var(--font-size-s);
}
.pointer-disabled {
pointer-events: none;
}
</style> </style>
<style lang="scss"> <style lang="scss">