diff --git a/cypress/composables/folders.ts b/cypress/composables/folders.ts index 496e1602e9..41e94f9611 100644 --- a/cypress/composables/folders.ts +++ b/cypress/composables/folders.ts @@ -76,7 +76,11 @@ export function getVisibleListBreadcrumbs() { } 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() { @@ -117,6 +121,11 @@ export function getListActionsToggle() { 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) { return cy .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) { return getFolderCard(folderName).find('[data-test-id="folder-card-actions"]'); } @@ -303,7 +316,9 @@ export function renameFolderFromListActions(folderName: string, newName: string) getFolderCard(folderName).click(); getListActionsToggle().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) { @@ -372,12 +387,9 @@ export function deleteAndTransferFolderContentsFromCardDropdown( export function deleteAndTransferFolderContentsFromListDropdown(destinationName: string) { getListActionsToggle().click(); getListActionItem('delete').click(); - getCurrentBreadcrumb() - .find('span') - .invoke('text') - .then((currentFolderName) => { - deleteFolderAndMoveContents(currentFolderName, destinationName); - }); + getCurrentBreadcrumbText().then((currentFolderName) => { + deleteFolderAndMoveContents(String(currentFolderName), destinationName); + }); } export function createNewProject(projectName: string, options: { openAfterCreate?: boolean } = {}) { diff --git a/cypress/e2e/49-folders.cy.ts b/cypress/e2e/49-folders.cy.ts index 00ebc00259..061ddcb7dc 100644 --- a/cypress/e2e/49-folders.cy.ts +++ b/cypress/e2e/49-folders.cy.ts @@ -19,7 +19,8 @@ import { duplicateWorkflowFromCardActions, duplicateWorkflowFromWorkflowPage, getAddResourceDropdown, - getCurrentBreadcrumb, + getCanvasBreadcrumbs, + getCurrentBreadcrumbText, getFolderCard, getFolderCardActionItem, getFolderCardActionToggle, @@ -75,7 +76,7 @@ describe('Folders', () => { getFolderCards().should('have.length.greaterThan', 0); // Clicking on the success toast should navigate to the folder successToast().find('a').click(); - getCurrentBreadcrumb().should('contain.text', 'My Folder'); + getCurrentBreadcrumbText().should('equal', 'My Folder'); // 2. In a folder createFolderFromListHeaderButton('My Folder 2'); getFolderCard('My Folder 2').should('exist'); @@ -116,7 +117,7 @@ describe('Folders', () => { getFolderCards().should('have.length.greaterThan', 0); // Clicking on the success toast should navigate to the folder 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', () => { @@ -137,7 +138,7 @@ describe('Folders', () => { successToast().should('exist'); // Should be automatically navigated to the new folder 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', () => { @@ -146,15 +147,15 @@ describe('Folders', () => { // Open folder using menu item getFolderCardActionToggle('Navigate Test').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 createFolderFromListHeaderButton('Child Folder'); getFolderCard('Child Folder').should('exist'); getFolderCard('Child Folder').click(); - getCurrentBreadcrumb().should('contain.text', 'Child Folder'); + getCurrentBreadcrumbText().should('equal', 'Child Folder'); // Navigate back to parent folder using breadcrumbs getVisibleListBreadcrumbs().contains('Navigate Test').click(); - getCurrentBreadcrumb().should('contain.text', 'Navigate Test'); + getCurrentBreadcrumbText().should('equal', 'Navigate Test'); // Go back to home project using breadcrumbs getHomeProjectBreadcrumb().click(); getListBreadcrumbs().should('not.exist'); @@ -168,14 +169,14 @@ describe('Folders', () => { // One level deep: // - Breadcrumbs should only show home project and current folder getHomeProjectBreadcrumb().should('exist'); - getCurrentBreadcrumb().should('contain.text', 'Multi-level Test'); + getCurrentBreadcrumbText().should('equal', 'Multi-level Test'); getFolderCard('Child Folder').should('exist'); createFolderInsideFolder('Child Folder 2', 'Child Folder'); // Two levels deep: // - Breadcrumbs should also show parent folder, without hidden ellipsis getHomeProjectBreadcrumb().should('exist'); - getCurrentBreadcrumb().should('contain.text', 'Child Folder'); + getCurrentBreadcrumbText().should('equal', 'Child Folder'); getVisibleListBreadcrumbs().should('have.length', 1); getMainBreadcrumbsEllipsis().should('not.exist'); @@ -202,7 +203,7 @@ describe('Folders', () => { cy.reload(); // Main list breadcrumbs should show home project, parent, grandparent, with one hidden element getHomeProjectBreadcrumb().should('exist'); - getCurrentBreadcrumb().should('contain.text', 'Child Folder 2'); + getCurrentBreadcrumbText().should('equal', 'Child Folder 2'); getVisibleListBreadcrumbs().should('have.length', 1); getVisibleListBreadcrumbs().first().should('contain.text', 'Child Folder'); getMainBreadcrumbsEllipsis().should('exist'); @@ -290,6 +291,24 @@ describe('Folders', () => { getFolderCard('Workflows go here').click(); 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', () => { @@ -298,7 +317,7 @@ describe('Folders', () => { createFolderFromProjectHeader('Rename Me'); getFolderCard('Rename Me').should('exist'); renameFolderFromListActions('Rename Me', 'Renamed'); - getCurrentBreadcrumb().should('contain.text', 'Renamed'); + getCurrentBreadcrumbText().should('equal', 'Renamed'); }); it('should rename folder from card dropdown', () => { @@ -416,7 +435,7 @@ describe('Folders', () => { createFolderFromProjectHeader('Destination 5'); moveFolderFromListActions('Move me too - I am empty', 'Destination 5'); // Since we moved the current folder, we should be in the destination folder - getCurrentBreadcrumb().should('contain.text', 'Destination 5'); + getCurrentBreadcrumbText().should('equal', 'Destination 5'); }); it('should move folder with contents to another folder - from list dropdown', () => { @@ -432,7 +451,7 @@ describe('Folders', () => { // Move the folder moveFolderFromListActions('Move me - I have family 2', 'Destination 6'); // Since we moved the current folder, we should be in the destination folder - getCurrentBreadcrumb().should('contain.text', 'Destination 6'); + getCurrentBreadcrumbText().should('equal', 'Destination 6'); // Moved folder should be there getFolderCard('Move me - I have family 2').should('exist').click(); // After navigating to the moved folder, both the workflow and the folder should be there @@ -456,7 +475,7 @@ describe('Folders', () => { getFolderCard('Move me to root').click(); getHomeProjectBreadcrumb().should('contain.text', 'Personal'); getListBreadcrumbs().findChildByTestId('breadcrumbs-item').should('not.exist'); - getCurrentBreadcrumb().should('contain.text', 'Move me to root'); + getCurrentBreadcrumbText().should('equal', 'Move me to root'); }); it('should move workflow from project root to folder', () => { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue index 32dac9db47..f5d3b2e600 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/Breadcrumbs.vue @@ -235,17 +235,15 @@ const handleTooltipClose = () => { :class="{ [$style.item]: true, [$style.current]: props.highlightLastItem && index === items.length - 1, - [$style.dragging]: props.dragActive && index !== items.length - 1, + [$style.dragging]: props.dragActive, }" :title="item.label" - :data-test-id=" - index === items.length - 1 ? 'breadcrumbs-item-current' : 'breadcrumbs-item' - " :data-resourceid="item.id" + data-test-id="breadcrumbs-item" data-target="folder-breadcrumb-item" @click.prevent="emitItemSelected(item.id)" @mouseenter="emitItemHover(item.id)" - @mouseup="index !== items.length - 1 ? onItemMouseUp(item) : {}" + @mouseup="onItemMouseUp(item)" > {{ item.label }} {{ item.label }} @@ -330,8 +328,6 @@ const handleTooltipClose = () => { .hidden-items-menu { display: flex; - position: relative; - top: var(--spacing-5xs); color: var(--color-text-base); } @@ -411,7 +407,7 @@ const handleTooltipClose = () => { .item * { color: var(--color-text-base); font-size: var(--font-size-2xs); - line-height: var(--font-line-heigh-xsmall); + line-height: var(--font-line-height-xsmall); } .item a:hover * { @@ -427,13 +423,14 @@ const handleTooltipClose = () => { // Medium theme overrides .medium { li { - padding: var(--spacing-4xs); + padding: var(--spacing-3xs) var(--spacing-4xs) var(--spacing-4xs); } .item, .item * { color: var(--color-text-base); font-size: var(--font-size-s); + line-height: var(--font-line-height-xsmall); } .item { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap index 4a73ea746d..152c4e706d 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap +++ b/packages/frontend/@n8n/design-system/src/components/N8nBreadcrumbs/__snapshots__/BreadCrumbs.test.ts.snap @@ -6,13 +6,13 @@ exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = fal -
  • Folder 1
  • +
  • Folder 1
  • /
  • -
  • Folder 2
  • +
  • Folder 2
  • /
  • -
  • Folder 3
  • +
  • Folder 3
  • /
  • -
  • Current
  • +
  • Current
  • " @@ -24,13 +24,13 @@ exports[`Breadcrumbs > renders custom separator correctly 1`] = ` -
  • Folder 1
  • +
  • Folder 1
  • -
  • Folder 2
  • +
  • Folder 2
  • -
  • Folder 3
  • +
  • Folder 3
  • -
  • Current
  • +
  • Current
  • " @@ -42,13 +42,13 @@ exports[`Breadcrumbs > renders default version correctly 1`] = ` -
  • Folder 1
  • +
  • Folder 1
  • /
  • -
  • Folder 2
  • +
  • Folder 2
  • /
  • -
  • Folder 3
  • +
  • Folder 3
  • /
  • -
  • Current
  • +
  • Current
  • " @@ -61,13 +61,13 @@ exports[`Breadcrumbs > renders slots correctly 1`] = `
  • /
  • -
  • Folder 1
  • +
  • Folder 1
  • /
  • -
  • Folder 2
  • +
  • Folder 2
  • /
  • -
  • Folder 3
  • +
  • Folder 3
  • /
  • -
  • Current
  • +
  • Current
  • [POST] Custom content
    @@ -80,13 +80,13 @@ exports[`Breadcrumbs > renders small version correctly 1`] = ` -
  • Folder 1
  • +
  • Folder 1
  • /
  • -
  • Folder 2
  • +
  • Folder 2
  • /
  • -
  • Folder 3
  • +
  • Folder 3
  • /
  • -
  • Current
  • +
  • Current
  • " diff --git a/packages/frontend/@n8n/design-system/src/css/_tokens.scss b/packages/frontend/@n8n/design-system/src/css/_tokens.scss index 6aa16e2152..7a41a36e5c 100644 --- a/packages/frontend/@n8n/design-system/src/css/_tokens.scss +++ b/packages/frontend/@n8n/design-system/src/css/_tokens.scss @@ -616,7 +616,7 @@ --font-size-xl: 1.25rem; --font-size-2xl: 1.75rem; - --font-line-heigh-xsmall: 1; + --font-line-height-xsmall: 1; --font-line-height-compact: 1.25; --font-line-height-regular: 1.3; --font-line-height-loose: 1.35; diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 682862b84a..502496a775 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -328,7 +328,13 @@ export interface IWorkflowDb { versionId: string; usedCredentials?: IUsedCredential[]; 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 @@ -348,7 +354,6 @@ export type FolderShortInfo = { id: string; name: string; parentFolder?: string; - parentFolderId?: string | null; }; export type BaseFolderItem = BaseResource & { @@ -356,12 +361,18 @@ export type BaseFolderItem = BaseResource & { updatedAt: string; workflowCount: number; subFolderCount: number; - parentFolder?: FolderShortInfo; + parentFolder?: ResourceParentFolder; homeProject?: ProjectSharingData; sharedWithProjects?: ProjectSharingData[]; tags?: ITag[]; }; +export type ResourceParentFolder = { + id: string; + name: string; + parentFolderId: string | null; +}; + export interface FolderListItem extends BaseFolderItem { resource: 'folder'; } diff --git a/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue b/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue index 666b29ef68..452c843882 100644 --- a/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue +++ b/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputBase.vue @@ -27,16 +27,13 @@ const hiddenValue = computed(() => { diff --git a/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue b/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue index d4f2851583..a502c120ed 100644 --- a/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue +++ b/packages/frontend/editor-ui/src/components/ExpandableInput/ExpandableInputPreview.vue @@ -10,18 +10,18 @@ defineProps(); diff --git a/packages/frontend/editor-ui/src/components/Folders/ProjectBreadcrumb.test.ts b/packages/frontend/editor-ui/src/components/Folders/ProjectBreadcrumb.test.ts new file mode 100644 index 0000000000..41529a7dba --- /dev/null +++ b/packages/frontend/editor-ui/src/components/Folders/ProjectBreadcrumb.test.ts @@ -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: '', + props: ['to'], + }, + ProjectIcon: { + template: + '
    ', + props: ['icon', 'borderLess', 'size', 'title'], + }, + N8nText: { + template: '', + 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, + }, + }); + 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, + }, + }); + 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, + }, + }); + 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, + }, + }); + 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, + }, + }); + 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, + }, + }); + await fireEvent.mouseUp(getByTestId('home-project')); + expect(emitted('projectDrop')).toBeFalsy(); + }); +}); diff --git a/packages/frontend/editor-ui/src/components/Folders/ProjectBreadcrumb.vue b/packages/frontend/editor-ui/src/components/Folders/ProjectBreadcrumb.vue new file mode 100644 index 0000000000..d4faa09370 --- /dev/null +++ b/packages/frontend/editor-ui/src/components/Folders/ProjectBreadcrumb.vue @@ -0,0 +1,101 @@ + + + + diff --git a/packages/frontend/editor-ui/src/components/InlineTextEdit.vue b/packages/frontend/editor-ui/src/components/InlineTextEdit.vue index 8fb4657d71..76442eb7ae 100644 --- a/packages/frontend/editor-ui/src/components/InlineTextEdit.vue +++ b/packages/frontend/editor-ui/src/components/InlineTextEdit.vue @@ -40,6 +40,15 @@ watch( }, ); +watch( + () => props.modelValue, + (value) => { + if (isDisabled.value) return; + newValue.value = value; + }, + { immediate: true }, +); + function onInput(val: string) { if (isDisabled.value) return; newValue.value = val; @@ -79,7 +88,7 @@ function onEscape() { -