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

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

View File

@@ -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>