mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat(editor): Show workflow breadcrumbs in canvas (#14710)
This commit is contained in:
committed by
GitHub
parent
be53453def
commit
46df8b47d6
@@ -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 } = {}) {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 you’d 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 you’d 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",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user