mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Show workflow breadcrumbs in canvas (#14710)
This commit is contained in:
committed by
GitHub
parent
be53453def
commit
46df8b47d6
@@ -27,16 +27,13 @@ const hiddenValue = computed(() => {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$--horiz-padding: 15px;
|
||||
|
||||
.el-input {
|
||||
display: inline-grid;
|
||||
font: inherit;
|
||||
padding: 10px 0;
|
||||
|
||||
:deep(input) {
|
||||
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%;
|
||||
grid-area: 1 / 2;
|
||||
font: inherit;
|
||||
@@ -48,7 +45,7 @@ $--horiz-padding: 15px;
|
||||
content: attr(data-value) ' ';
|
||||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
padding: 0 $--horiz-padding;
|
||||
padding: var(--spacing-3xs);
|
||||
}
|
||||
|
||||
&:not(.static-size)::after {
|
||||
|
||||
@@ -37,6 +37,7 @@ onBeforeUnmount(() => {
|
||||
function focus() {
|
||||
if (inputRef.value) {
|
||||
inputRef.value.focus();
|
||||
inputRef.value.select();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +68,7 @@ function onEscape() {
|
||||
<ExpandableInputBase :model-value="modelValue" :placeholder="placeholder">
|
||||
<input
|
||||
ref="inputRef"
|
||||
class="el-input__inner"
|
||||
:class="['el-input__inner', $style.input]"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxlength"
|
||||
@@ -78,3 +79,10 @@ function onEscape() {
|
||||
/>
|
||||
</ExpandableInputBase>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.input {
|
||||
padding: var(--spacing-4xs);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,18 +10,18 @@ defineProps<Props>();
|
||||
|
||||
<template>
|
||||
<ExpandableInputBase :model-value="modelValue" :static-size="true">
|
||||
<input
|
||||
:class="{ 'el-input__inner': true, clickable: true }"
|
||||
:value="modelValue"
|
||||
:disabled="true"
|
||||
size="4"
|
||||
/>
|
||||
<input class="clickable preview" :value="modelValue" :disabled="true" size="4" />
|
||||
</ExpandableInputBase>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
input,
|
||||
input:hover {
|
||||
input.preview {
|
||||
padding: var(--spacing-4xs);
|
||||
border-radius: var(--border-radius-base);
|
||||
}
|
||||
|
||||
.preview,
|
||||
.preview:hover {
|
||||
background-color: unset;
|
||||
transition: unset;
|
||||
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">
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
import type { FolderPathItem } from '@/Interface';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { ProjectTypes } from '@/types/projects.types';
|
||||
import type { UserAction } from '@n8n/design-system/types';
|
||||
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 type { FolderPathItem, FolderShortInfo } from '@/Interface';
|
||||
|
||||
type Props = {
|
||||
actions: UserAction[];
|
||||
breadcrumbs: {
|
||||
visibleItems: FolderPathItem[];
|
||||
hiddenItems: FolderPathItem[];
|
||||
};
|
||||
// Current folder can be null when showing breadcrumbs for workflows in project root
|
||||
currentFolder?: FolderShortInfo | null;
|
||||
actions?: UserAction[];
|
||||
hiddenItemsTrigger?: 'hover' | 'click';
|
||||
currentFolderAsLink?: boolean;
|
||||
visibleLevels?: 1 | 2;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentFolder: null,
|
||||
actions: () => [],
|
||||
hiddenItemsTrigger: 'click',
|
||||
currentFolderAsLink: false,
|
||||
visibleLevels: 1,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -33,6 +37,11 @@ const i18n = useI18n();
|
||||
const projectsStore = useProjectsStore();
|
||||
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 projectName = computed(() => {
|
||||
@@ -46,6 +55,64 @@ const isDragging = computed(() => {
|
||||
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) => {
|
||||
emit('itemSelected', item);
|
||||
};
|
||||
@@ -54,8 +121,8 @@ const onAction = (action: string) => {
|
||||
emit('action', action);
|
||||
};
|
||||
|
||||
const onProjectMouseUp = () => {
|
||||
if (!isDragging.value || !currentProject.value?.name) {
|
||||
const onProjectDrop = () => {
|
||||
if (!currentProject.value?.name) {
|
||||
return;
|
||||
}
|
||||
emit('projectDrop', currentProject.value.id, currentProject.value.name);
|
||||
@@ -82,6 +149,22 @@ const onItemHover = (item: PathItem) => {
|
||||
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>
|
||||
<template>
|
||||
<div
|
||||
@@ -89,33 +172,45 @@ const onItemHover = (item: PathItem) => {
|
||||
data-test-id="folder-breadcrumbs"
|
||||
>
|
||||
<n8n-breadcrumbs
|
||||
v-if="breadcrumbs.visibleItems"
|
||||
v-if="visibleBreadcrumbsItems.length"
|
||||
v-model:drag-active="isDragging"
|
||||
:items="breadcrumbs.visibleItems"
|
||||
:items="visibleBreadcrumbsItems"
|
||||
:highlight-last-item="false"
|
||||
:path-truncated="breadcrumbs.visibleItems[0].parentFolder"
|
||||
:hidden-items="breadcrumbs.hiddenItems"
|
||||
:path-truncated="hasMoreItems"
|
||||
:hidden-items="hasMoreItems ? hiddenBreadcrumbsItemsAsync : undefined"
|
||||
:hidden-items-trigger="props.hiddenItemsTrigger"
|
||||
data-test-id="folder-list-breadcrumbs"
|
||||
@tooltip-opened="fetchHiddenBreadCrumbsItems"
|
||||
@item-selected="onItemSelect"
|
||||
@item-hover="onItemHover"
|
||||
@item-drop="emit('itemDrop', $event)"
|
||||
>
|
||||
<template v-if="currentProject" #prepend>
|
||||
<div
|
||||
:class="{ [$style['home-project']]: true, [$style.dragging]: isDragging }"
|
||||
data-test-id="home-project"
|
||||
@mouseenter="onProjectHover"
|
||||
@mouseup="isDragging ? onProjectMouseUp() : null"
|
||||
>
|
||||
<n8n-link :to="`/projects/${currentProject.id}`">
|
||||
<N8nText size="medium" color="text-base">{{ projectName }}</N8nText>
|
||||
</n8n-link>
|
||||
</div>
|
||||
<ProjectBreadcrumb
|
||||
:current-project="currentProject"
|
||||
:is-dragging="isDragging"
|
||||
@project-drop="onProjectDrop"
|
||||
@project-hover="onProjectHover"
|
||||
/>
|
||||
</template>
|
||||
<template #append>
|
||||
<slot name="append"></slot>
|
||||
</template>
|
||||
</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
|
||||
v-if="breadcrumbs.visibleItems"
|
||||
v-if="visibleBreadcrumbsItems && actions?.length"
|
||||
:actions="actions"
|
||||
:class="$style['action-toggle']"
|
||||
theme="dark"
|
||||
@@ -131,30 +226,14 @@ const onItemHover = (item: PathItem) => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.home-project {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-toggle {
|
||||
span[role='button'] {
|
||||
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>
|
||||
|
||||
@@ -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) {
|
||||
if (isDisabled.value) return;
|
||||
newValue.value = val;
|
||||
@@ -79,7 +88,7 @@ function onEscape() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-edit" @keydown.stop>
|
||||
<span :class="$style['inline-edit']" @keydown.stop>
|
||||
<span v-if="isEditEnabled && !isDisabled">
|
||||
<ExpandableInputEdit
|
||||
v-model="newValue"
|
||||
@@ -87,6 +96,7 @@ function onEscape() {
|
||||
:maxlength="maxLength"
|
||||
:autofocus="true"
|
||||
:event-bus="inputBus"
|
||||
data-test-id="inline-edit-input"
|
||||
@update:model-value="onInput"
|
||||
@esc="onEscape"
|
||||
@blur="onBlur"
|
||||
@@ -94,13 +104,25 @@ function onEscape() {
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span v-else class="preview" @click="onClick">
|
||||
<span v-else :class="$style.preview" @click="onClick">
|
||||
<ExpandableInputPreview :model-value="previewValue || modelValue" />
|
||||
</span>
|
||||
</span>
|
||||
</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 {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import GithubButton from 'vue-github-button';
|
||||
import type { FolderShortInfo } from '@/Interface';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@@ -94,6 +95,17 @@ const showGitHubButton = computed(
|
||||
() => !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) => {
|
||||
syncTabsWithRoute(to, from);
|
||||
});
|
||||
@@ -238,6 +250,7 @@ function hideGithubButton() {
|
||||
:scopes="workflow.scopes"
|
||||
:active="workflow.active"
|
||||
:read-only="readOnly"
|
||||
:current-folder="parentFolderForBreadcrumbs"
|
||||
/>
|
||||
<div v-if="showGitHubButton" :class="[$style['github-button'], 'hidden-sm-and-down']">
|
||||
<div :class="$style['github-button-container']">
|
||||
@@ -288,17 +301,19 @@ function hideGithubButton() {
|
||||
.top-menu {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: var(--navbar--height);
|
||||
align-items: center;
|
||||
font-size: 0.9em;
|
||||
font-weight: var(--font-weight-regular);
|
||||
overflow: auto;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.github-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
padding: var(--spacing-5xs) var(--spacing-m) 0;
|
||||
padding: var(--spacing-5xs) var(--spacing-m);
|
||||
background-color: var(--color-background-xlight);
|
||||
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 userEvent from '@testing-library/user-event';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => vi.fn(),
|
||||
useRouter: () => vi.fn(),
|
||||
RouterLink: vi.fn(),
|
||||
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(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/pushConnection.store', () => ({
|
||||
@@ -46,6 +51,9 @@ const renderComponent = createComponentRenderer(WorkflowDetails, {
|
||||
global: {
|
||||
stubs: {
|
||||
RouterLink: true,
|
||||
FolderBreadcrumbs: {
|
||||
template: '<div><slot name="append" /></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -63,6 +71,9 @@ describe('WorkflowDetails', () => {
|
||||
uiStore = useUIStore();
|
||||
});
|
||||
it('renders workflow name and tags', async () => {
|
||||
(useRoute as Mock).mockReturnValue({
|
||||
query: { parentFolderId: '1' },
|
||||
});
|
||||
const { getByTestId, getByText } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
|
||||
@@ -46,6 +46,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { computed, ref, useCssModule, watch } from 'vue';
|
||||
import type {
|
||||
ActionDropdownItem,
|
||||
FolderShortInfo,
|
||||
IWorkflowDataUpdate,
|
||||
IWorkflowDb,
|
||||
IWorkflowToShare,
|
||||
@@ -56,6 +57,8 @@ import type { BaseTextKey } from '@/plugins/i18n';
|
||||
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
|
||||
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
|
||||
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<{
|
||||
readOnly?: boolean;
|
||||
@@ -65,6 +68,7 @@ const props = defineProps<{
|
||||
meta: IWorkflowDb['meta'];
|
||||
scopes: IWorkflowDb['scopes'];
|
||||
active: IWorkflowDb['active'];
|
||||
currentFolder?: FolderShortInfo;
|
||||
}>();
|
||||
|
||||
const $style = useCssModule();
|
||||
@@ -78,6 +82,7 @@ const uiStore = useUIStore();
|
||||
const usersStore = useUsersStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
const npsSurveyStore = useNpsSurveyStore();
|
||||
const i18n = useI18n();
|
||||
|
||||
@@ -139,6 +144,11 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||
label: locale.baseText('menuActions.download'),
|
||||
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) {
|
||||
@@ -201,19 +211,6 @@ const workflowTagIds = computed(() => {
|
||||
return (props.tags ?? []).map((tag) => (typeof tag === 'string' ? tag : tag.id));
|
||||
});
|
||||
|
||||
const currentFolder = computed(() => {
|
||||
if (props.id === PLACEHOLDER_EMPTY_WORKFLOW_ID) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const workflow = workflowsStore.getWorkflowById(props.id);
|
||||
if (!workflow) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return workflow.parentFolder;
|
||||
});
|
||||
|
||||
const currentProjectName = computed(() => {
|
||||
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
|
||||
return locale.baseText('projects.menu.personal');
|
||||
@@ -221,6 +218,18 @@ const currentProjectName = computed(() => {
|
||||
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(
|
||||
() => props.id,
|
||||
() => {
|
||||
@@ -345,8 +354,8 @@ async function onNameSubmit({
|
||||
const newName = name.trim();
|
||||
if (!newName) {
|
||||
toast.showMessage({
|
||||
title: locale.baseText('workflowDetails.showMessage.title'),
|
||||
message: locale.baseText('workflowDetails.showMessage.message'),
|
||||
title: locale.baseText('renameAction.emptyName.title'),
|
||||
message: locale.baseText('renameAction.emptyName.message'),
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
@@ -408,11 +417,15 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
tags: props.tags,
|
||||
parentFolderId: currentFolder?.value?.id,
|
||||
parentFolderId: props.currentFolder?.id,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.RENAME: {
|
||||
onNameToggle();
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.DOWNLOAD: {
|
||||
const workflowData = await workflowHelpers.getWorkflowDataToSave();
|
||||
const { tags, ...data } = workflowData;
|
||||
@@ -556,11 +569,11 @@ function showCreateWorkflowSuccessToast(id?: string) {
|
||||
let toastText = locale.baseText('workflows.create.personal.toast.text');
|
||||
|
||||
if (projectsStore.currentProject) {
|
||||
if (currentFolder.value) {
|
||||
if (props.currentFolder) {
|
||||
toastTitle = locale.baseText('workflows.create.folder.toast.title', {
|
||||
interpolate: {
|
||||
projectName: currentProjectName.value ?? '',
|
||||
folderName: currentFolder.value.name ?? '',
|
||||
folderName: props.currentFolder.name ?? '',
|
||||
},
|
||||
});
|
||||
} 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>
|
||||
|
||||
<template>
|
||||
<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 }">
|
||||
<ShortenName :name="name" :limit="value" :custom="true" test-id="workflow-name-input">
|
||||
<template #default="{ shortenedName }">
|
||||
<InlineTextEdit
|
||||
:model-value="name"
|
||||
: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"
|
||||
/>
|
||||
<FolderBreadcrumbs
|
||||
:current-folder="currentFolderForBreadcrumbs"
|
||||
:current-folder-as-link="true"
|
||||
@item-selected="onBreadcrumbsItemSelected"
|
||||
>
|
||||
<template #append>
|
||||
<span v-if="projectsStore.currentProject" :class="$style['path-separator']">/</span>
|
||||
<ShortenName :name="name" :limit="value" :custom="true" test-id="workflow-name-input">
|
||||
<template #default="{ shortenedName }">
|
||||
<InlineTextEdit
|
||||
:model-value="name"
|
||||
: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>
|
||||
</ShortenName>
|
||||
</FolderBreadcrumbs>
|
||||
</template>
|
||||
</BreakpointsObserver>
|
||||
|
||||
@@ -728,7 +764,7 @@ $--text-line-height: 24px;
|
||||
$--header-spacing: 20px;
|
||||
|
||||
.name-container {
|
||||
margin-right: $--header-spacing;
|
||||
margin-right: var(--spacing-s);
|
||||
|
||||
:deep(.el-input) {
|
||||
padding: 0;
|
||||
@@ -737,8 +773,7 @@ $--header-spacing: 20px;
|
||||
|
||||
.name {
|
||||
color: $custom-font-dark;
|
||||
font-size: 15px;
|
||||
display: block;
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
.activator {
|
||||
@@ -805,6 +840,12 @@ $--header-spacing: 20px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.path-separator {
|
||||
font-size: var(--font-size-xl);
|
||||
color: var(--color-foreground-base);
|
||||
margin: var(--spacing-4xs);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import type { BaseTextKey } from '@/plugins/i18n';
|
||||
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';
|
||||
|
||||
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
|
||||
@@ -34,7 +34,7 @@ export type WorkflowResource = BaseResource & {
|
||||
tags?: ITag[] | string[];
|
||||
sharedWithProjects?: ProjectSharingData[];
|
||||
readOnly: boolean;
|
||||
parentFolder?: FolderShortInfo;
|
||||
parentFolder?: ResourceParentFolder;
|
||||
};
|
||||
|
||||
export type VariableResource = BaseResource & {
|
||||
|
||||
Reference in New Issue
Block a user