feat(editor): Implement breadcrumbs component (#13317)

Co-authored-by: Rob Squires <robtf9@icloud.com>
This commit is contained in:
Milorad FIlipović 2025-02-19 14:46:30 +01:00 committed by GitHub
parent 5439181e92
commit db297f107d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1174 additions and 1 deletions

View file

@ -16,6 +16,9 @@ interface ActionToggleProps {
iconSize?: IconSize;
theme?: (typeof THEME)[number];
iconOrientation?: IconOrientation;
loading?: boolean;
loadingRowCount?: number;
disabled?: boolean;
}
defineOptions({ name: 'N8nActionToggle' });
@ -24,7 +27,11 @@ withDefaults(defineProps<ActionToggleProps>(), {
placement: 'bottom',
size: 'medium',
theme: 'default',
iconSize: 'medium',
iconOrientation: 'vertical',
loading: false,
loadingRowCount: 3,
disabled: false,
});
const emit = defineEmits<{
@ -40,6 +47,7 @@ const onVisibleChange = (value: boolean) => emit('visible-change', value);
<ElDropdown
:placement="placement"
:size="size"
:disabled="disabled"
trigger="click"
@command="onCommand"
@visible-change="onVisibleChange"
@ -54,7 +62,18 @@ const onVisibleChange = (value: boolean) => emit('visible-change', value);
</slot>
<template #dropdown>
<ElDropdownMenu data-test-id="action-toggle-dropdown">
<ElDropdownMenu
v-if="loading"
:class="$style['loading-dropdown']"
data-test-id="action-toggle-loading-dropdown"
>
<ElDropdownItem v-for="index in loadingRowCount" :key="index" :disabled="true">
<template #default>
<N8nLoading :class="$style.loading" animated variant="text" />
</template>
</ElDropdownItem>
</ElDropdownMenu>
<ElDropdownMenu v-else data-test-id="action-toggle-dropdown">
<ElDropdownItem
v-for="action in actions"
:key="action.value"
@ -113,4 +132,17 @@ const onVisibleChange = (value: boolean) => emit('visible-change', value);
li:hover .iconContainer svg {
color: var(--color-primary-tint-1);
}
.loading-dropdown {
display: flex;
flex-direction: column;
padding: var(--spacing-xs) 0;
gap: var(--spacing-2xs);
}
.loading {
display: flex;
width: 100%;
min-width: var(--spacing-3xl);
}
</style>

View file

@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue';
import type { PathItem } from './Breadcrumbs.vue';
import Breadcrumbs from './Breadcrumbs.vue';
/**
* Demo component to showcase different usage of Breadcrumbs component
*/
defineOptions({ name: 'AsyncLoadingCacheDemo' });
const FETCH_TIMEOUT = 1000;
type Props = {
title: string;
mode: 'sync' | 'async';
testCache?: boolean;
// Breadcrumbs props
theme?: 'small' | 'medium';
showBorder?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
mode: 'sync',
testCache: false,
theme: 'medium',
showBorder: false,
});
// We'll use this to break the cache after a few fetches in the demo
const fetchCount = ref(0);
const items = ref<PathItem[]>([
{ id: '1', label: 'Home', href: '/' },
{ id: '2', label: 'Parent', href: '/parent' },
]);
// For async version, hidden items are a promise, we can also make it reactive like this
const hiddenItemsPromise = ref<Promise<PathItem[]>>(new Promise(() => {}));
// For sync version, hidden items are a just a reactive array
const hiddenItemsSync = ref<PathItem[]>([
{ id: 'folder2', label: 'Folder 2' },
{ id: 'folder3', label: 'Folder 3' },
]);
const onDropdownOpened = () => {
// In the current implementation, we need to init a promise only when needed
if (fetchCount.value === 0) {
hiddenItemsPromise.value = fetchHiddenItems();
}
// Demo code
fetchCount.value++;
if (props.testCache) {
if (fetchCount.value > 2 && props.mode === 'async') {
updatePromise();
} else if (props.mode === 'sync') {
updateSyncItems();
}
}
};
const fetchHiddenItems = async (): Promise<PathItem[]> => {
await new Promise((resolve) => setTimeout(resolve, FETCH_TIMEOUT));
return [
{ id: 'home', label: 'Home' },
{ id: 'projects', label: 'Projects' },
{ id: 'folder1', label: 'Folder 1' },
];
};
// Updates the promise ref with new items to showcase it's reactivity
const updatePromise = () => {
hiddenItemsPromise.value = new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: 'folder1', label: 'New Folder 1' },
{ id: 'folder2', label: 'Folder 2' },
{ id: 'folder3', label: 'Folder 3' },
]);
}, FETCH_TIMEOUT);
});
};
// Updates the sync array to showcase it's reactivity
const updateSyncItems = () => {
const newId = fetchCount.value + 3;
const newItem = { id: `'folder${newId}'`, label: `Folder ${newId}` };
hiddenItemsSync.value.push(newItem);
};
</script>
<template>
<div :class="$style.container">
<div :class="$style.heading">{{ title }}</div>
<div :class="$style.breadcrumbs">
<Breadcrumbs
v-if="props.mode === 'sync'"
:items="items"
:hidden-items="hiddenItemsSync"
:theme="theme"
:show-border="props.showBorder"
@tooltip-opened="onDropdownOpened"
/>
<Breadcrumbs
v-else-if="props.mode === 'async'"
:items="items"
:hidden-items="hiddenItemsPromise"
:theme="theme"
:show-border="props.showBorder"
@tooltip-opened="onDropdownOpened"
/>
</div>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
flex-direction: column;
gap: 1em;
}
</style>

View file

@ -0,0 +1,330 @@
import userEvent from '@testing-library/user-event';
import { render } from '@testing-library/vue';
import { defineComponent } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import Breadcrumbs from '.';
import type { PathItem } from './Breadcrumbs.vue';
// Simple mock for the N8nActionToggle component to test hidden items and events
const N8nActionToggleMock = defineComponent({
name: 'N8nActionToggle',
props: {
actions: {
type: Array as () => Array<{ value: string; label: string }>,
required: true,
},
loading: {
type: Boolean,
default: false,
},
},
emits: ['visible-change', 'action'],
data() {
return {
showContent: false,
};
},
methods: {
handleClick() {
this.showContent = !this.showContent;
this.$emit('visible-change', this.showContent);
},
},
template: `
<div data-test-id="action-toggle-mock" class="mock-action-toggle">
<button
data-test-id="mock-dropdown-button"
@click="handleClick"
>
Toggle Dropdown
</button>
<div class="dropdown-content" v-if="showContent">
<div v-if="loading" data-test-id="loading-state">
Loading...
</div>
<template v-else>
<div
v-for="action in actions"
:key="action.value"
:data-test-id="'action-' + action.value"
class="dropdown-item"
@click="$emit('action', action.value)"
>
{{ action.label }}
</div>
</template>
</div>
</div>
`,
});
const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/', component: { template: '<div>Home</div>' } }],
});
describe('Breadcrumbs', async () => {
it('renders default version correctly', () => {
const wrapper = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
},
global: {
plugins: [router],
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('renders small version correctly', () => {
const wrapper = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
theme: 'small',
},
global: {
plugins: [router],
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('renders custom separator correctly', () => {
const wrapper = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
separator: '➮',
},
global: {
plugins: [router],
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('renders slots correctly', () => {
const wrapper = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
},
global: {
plugins: [router],
},
slots: {
prepend: '<div>[PRE] Custom content</div>',
append: '<div>[POST] Custom content</div>',
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('renders ellipsis when for "pathTruncated = true"', () => {
const { getByTestId } = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
pathTruncated: true,
},
global: {
plugins: [router],
},
});
expect(getByTestId('ellipsis')).toBeTruthy();
expect(getByTestId('action-toggle')).toBeTruthy();
expect(getByTestId('ellipsis')).toHaveClass('disabled');
expect(getByTestId('action-toggle').querySelector('.el-dropdown')).toHaveClass('is-disabled');
});
it('does not highlight last item for "highlightLastItem = false" ', () => {
const wrapper = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
highlightLastItem: false,
},
global: {
plugins: [router],
},
});
expect(wrapper.html()).toMatchSnapshot();
});
it('renders hidden items correctly', async () => {
const hiddenItems = [
{ id: '3', label: 'Parent 1', href: '/hidden1' },
{ id: '4', label: 'Parent 2', href: '/hidden2' },
];
const { container, getByTestId, queryByTestId } = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
hiddenItems,
},
global: {
stubs: {
'n8n-action-toggle': N8nActionToggleMock,
},
plugins: [router],
},
});
await userEvent.click(getByTestId('mock-dropdown-button'));
// Should not show the loading state
expect(queryByTestId('loading-state')).toBeFalsy();
const dropdownItems = container.querySelectorAll('.dropdown-item');
// Check if the number of rendered items matches the hidden items
expect(dropdownItems.length).toBe(hiddenItems.length);
hiddenItems.forEach((item, index) => {
const dropdownItem = dropdownItems[index];
expect(dropdownItem).toBeTruthy();
expect(dropdownItem.textContent?.trim()).toBe(item.label);
expect(dropdownItem.getAttribute('data-test-id')).toBe(`action-${item.id}`);
});
});
it('renders async hidden items correctly', async () => {
const FETCH_TIMEOUT = 1000;
const hiddenItems = [
{ id: 'home', label: 'Home' },
{ id: 'projects', label: 'Projects' },
{ id: 'folder1', label: 'Folder 1' },
];
const fetchHiddenItems = async (): Promise<PathItem[]> => {
await new Promise((resolve) => setTimeout(resolve, FETCH_TIMEOUT));
return hiddenItems;
};
const hiddenItemsPromise = fetchHiddenItems();
const { container, getByTestId, queryByTestId } = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
hiddenItems: hiddenItemsPromise,
},
global: {
stubs: {
'n8n-action-toggle': N8nActionToggleMock,
},
plugins: [router],
},
});
await userEvent.click(getByTestId('mock-dropdown-button'));
// Should show loading state at first
expect(container.querySelector('.dropdown-item')).toBeFalsy();
expect(getByTestId('loading-state')).toBeTruthy();
// Wait for the hidden items to be fetched
await new Promise((resolve) => setTimeout(resolve, FETCH_TIMEOUT));
// Should not show the loading state
expect(queryByTestId('loading-state')).toBeFalsy();
const dropdownItems = container.querySelectorAll('.dropdown-item');
// Check if the number of rendered items matches the hidden items
expect(dropdownItems.length).toBe(hiddenItems.length);
hiddenItems.forEach((item, index) => {
const dropdownItem = dropdownItems[index];
expect(dropdownItem).toBeTruthy();
expect(dropdownItem.textContent?.trim()).toBe(item.label);
expect(dropdownItem.getAttribute('data-test-id')).toBe(`action-${item.id}`);
});
});
it('emits event when item is clicked', async () => {
const items = [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
];
const hiddenItems = [
{ id: '3', label: 'Parent 1', href: '/hidden1' },
{ id: '4', label: 'Parent 2', href: '/hidden2' },
];
const { container, emitted, getByTestId, getAllByTestId } = render(Breadcrumbs, {
props: {
items,
hiddenItems,
},
global: {
stubs: {
'n8n-action-toggle': N8nActionToggleMock,
},
plugins: [router],
},
});
const visibleItems = getAllByTestId('breadcrumbs-item');
await userEvent.click(visibleItems[0]);
expect(emitted()).toHaveProperty('itemSelected');
expect(emitted().itemSelected[0]).toEqual([items[0]]);
// Click on the hidden item, should fire the same event
await userEvent.click(getByTestId('mock-dropdown-button'));
const dropdownItems = container.querySelectorAll('.dropdown-item');
await userEvent.click(dropdownItems[0]);
expect(emitted()).toHaveProperty('itemSelected');
expect(emitted().itemSelected[1]).toEqual([hiddenItems[0]]);
});
it('emits tooltipOpened and tooltipClosed events', async () => {
const { emitted, getByTestId } = render(Breadcrumbs, {
props: {
items: [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
],
hiddenItems: [
{ id: '3', label: 'Parent 1', href: '/hidden1' },
{ id: '4', label: 'Parent 2', href: '/hidden2' },
],
},
global: {
stubs: {
'n8n-action-toggle': N8nActionToggleMock,
},
plugins: [router],
},
});
await userEvent.click(getByTestId('mock-dropdown-button'));
expect(emitted()).toHaveProperty('tooltipOpened');
// close the tooltip
await userEvent.click(getByTestId('mock-dropdown-button'));
expect(emitted()).toHaveProperty('tooltipClosed');
});
});

View file

@ -0,0 +1,230 @@
import type { StoryFn } from '@storybook/vue3';
import type { UserAction } from 'n8n-design-system/types';
import AsyncLoadingCacheDemo from './AsyncLoadingCacheDemo.vue';
import Breadcrumbs from './Breadcrumbs.vue';
import type { PathItem } from './Breadcrumbs.vue';
import ActionToggle from '../N8nActionToggle/ActionToggle.vue';
import Tags from '../N8nTags/Tags.vue';
export default {
title: 'Atoms/Breadcrumbs',
component: Breadcrumbs,
argTypes: {
items: { control: 'object' },
hiddenItemsSource: { control: 'object' },
theme: {
control: {
type: 'select',
},
options: ['medium', 'small'],
},
showBorder: { control: 'boolean' },
tooltipTrigger: {
control: {
type: 'select',
},
options: ['hover', 'click'],
},
},
};
const items: PathItem[] = [
{ id: '1', label: 'Folder 1', href: '/folder1' },
{ id: '2', label: 'Folder 2', href: '/folder2' },
{ id: '3', label: 'Folder 3', href: '/folder3' },
{ id: '4', label: 'Current' },
];
const defaultTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { Breadcrumbs },
props: Object.keys(argTypes),
template: '<Breadcrumbs v-bind="args" />',
});
export const Default = defaultTemplate.bind({});
Default.args = {
items,
};
export const CustomSeparator = defaultTemplate.bind({});
CustomSeparator.args = {
items,
separator: '➮',
};
const withHiddenItemsTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { Breadcrumbs },
props: Object.keys(argTypes),
template: '<Breadcrumbs v-bind="args" />',
});
export const WithHiddenItems = withHiddenItemsTemplate.bind({});
WithHiddenItems.args = {
items: items.slice(2),
hiddenItems: [
{ id: '3', label: 'Parent 1', href: '/hidden1' },
{ id: '4', label: 'Parent 2', href: '/hidden2' },
],
};
const hiddenItemsDisabledTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { Breadcrumbs },
props: Object.keys(argTypes),
template: '<Breadcrumbs v-bind="args" />',
});
export const HiddenItemsDisabled = hiddenItemsDisabledTemplate.bind({});
HiddenItemsDisabled.args = {
items: items.slice(2),
pathTruncated: true,
};
const asyncLoadingTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { AsyncLoadingCacheDemo },
props: Object.keys(argTypes),
template: '<AsyncLoadingCacheDemo v-bind="args" />',
});
export const AsyncLoading = asyncLoadingTemplate.bind({});
AsyncLoading.args = {
mode: 'async',
title: '[Demo] Async loading with cached items',
};
const asyncLoadingNoCacheTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { AsyncLoadingCacheDemo },
props: Object.keys(argTypes),
template: '<AsyncLoadingCacheDemo v-bind="args" />',
});
export const AsyncLoadingCacheTest = asyncLoadingNoCacheTemplate.bind({});
AsyncLoadingCacheTest.args = {
mode: 'async',
testCache: true,
title: '[Demo] This will bust the cache after hidden items are loaded 2 times',
};
const syncLoadingNoCacheTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { AsyncLoadingCacheDemo },
props: Object.keys(argTypes),
template: '<AsyncLoadingCacheDemo v-bind="args" />',
});
export const SyncLoadingCacheTest = syncLoadingNoCacheTemplate.bind({});
SyncLoadingCacheTest.args = {
mode: 'sync',
testCache: true,
title: '[Demo] This will update the hidden items every time dropdown is opened',
};
const testActions: UserAction[] = [
{ label: 'Create Folder', value: 'action1', disabled: false },
{ label: 'Create Workflow', value: 'action2', disabled: false },
{ label: 'Rename', value: 'action3', disabled: false },
];
const testTags: Array<{ id: string; name: string }> = [
{ id: '1', name: 'tag1' },
{ id: '2', name: 'tag2' },
];
const withSlotsTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args, testActions, testTags }),
components: { Breadcrumbs, ActionToggle, Tags },
props: Object.keys(argTypes),
template: `<Breadcrumbs v-bind="args">
<template #prepend>
<div style="display: flex; align-items: center; gap: 8px;">
<n8n-icon icon="layer-group"/>
<n8n-text>My Project</n8n-text>
</div>
</template>
<template #append>
<div style="display: flex; align-items: center;">
<n8n-tags :tags="testTags" />
<n8n-action-toggle size="small" :actions="testActions" theme="dark"/>
</div>
</template>
</Breadcrumbs>`,
});
export const WithSlots = withSlotsTemplate.bind({});
WithSlots.args = {
items: items.slice(2),
hiddenItems: [
{ id: '3', label: 'Parent 1', href: '/hidden1' },
{ id: '4', label: 'Parent 2', href: '/hidden2' },
],
};
const smallVersionTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { Breadcrumbs },
props: Object.keys(argTypes),
template: '<Breadcrumbs v-bind="args" />',
});
export const SmallVersion = smallVersionTemplate.bind({});
SmallVersion.args = {
items,
theme: 'small',
showBorder: true,
};
const smallWithSlotsTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { Breadcrumbs },
props: Object.keys(argTypes),
template: `<Breadcrumbs v-bind="args">
<template #prepend>
<div style="display: flex; align-items: center; gap: 4px; font-size: 10px">
<n8n-icon icon="user"/>
<n8n-text>Personal</n8n-text>
</div>
</template>
</Breadcrumbs>`,
});
export const SmallWithSlots = smallWithSlotsTemplate.bind({});
SmallWithSlots.args = {
theme: 'small',
showBorder: true,
items: items.slice(2),
hiddenItems: [
{ id: '3', label: 'Parent 1', href: '/hidden1' },
{ id: '4', label: 'Parent 2', href: '/hidden2' },
],
};
const smallAsyncLoadingTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { AsyncLoadingCacheDemo },
props: Object.keys(argTypes),
template: '<AsyncLoadingCacheDemo v-bind="args" />',
});
export const SmallAsyncLoading = smallAsyncLoadingTemplate.bind({});
SmallAsyncLoading.args = {
mode: 'async',
title: '[Demo] Small version with async loading',
theme: 'small',
showBorder: true,
};
const smallHiddenItemsDisabledTemplate: StoryFn = (args, { argTypes }) => ({
setup: () => ({ args }),
components: { Breadcrumbs },
props: Object.keys(argTypes),
template: '<Breadcrumbs v-bind="args" />',
});
export const SmallWithHiddenItemsDisabled = smallHiddenItemsDisabledTemplate.bind({});
SmallWithHiddenItemsDisabled.args = {
theme: 'small',
showBorder: true,
items: items.slice(2),
pathTruncated: true,
};

View file

@ -0,0 +1,362 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import type { UserAction } from 'n8n-design-system/types';
import N8nLoading from '../N8nLoading';
export type PathItem = {
id: string;
label: string;
href?: string;
};
type Props = {
items: PathItem[];
hiddenItems?: PathItem[] | Promise<PathItem[]>;
theme?: 'small' | 'medium';
showBorder?: boolean;
loadingSkeletonRows?: number;
separator?: string;
highlightLastItem?: boolean;
// Setting this to true will show the ellipsis even if there are no hidden items
pathTruncated?: boolean;
};
defineOptions({ name: 'N8nBreadcrumbs' });
const emit = defineEmits<{
tooltipOpened: [];
tooltipClosed: [];
hiddenItemsLoadingError: [error: unknown];
itemSelected: [item: PathItem];
}>();
const props = withDefaults(defineProps<Props>(), {
hiddenItems: () => new Array<PathItem>(),
theme: 'medium',
showBorder: false,
loadingSkeletonRows: 3,
separator: '/',
highlightLastItem: true,
isPathTruncated: false,
});
const loadedHiddenItems = ref<PathItem[]>([]);
const isLoadingHiddenItems = ref(false);
const currentPromise = ref<Promise<PathItem[]> | null>(null);
const hasHiddenItems = computed(() => {
return Array.isArray(props.hiddenItems)
? props.hiddenItems.length > 0
: props.hiddenItems !== undefined;
});
const showEllipsis = computed(() => {
return hasHiddenItems.value || props.pathTruncated;
});
const dropdownDisabled = computed(() => {
return props.pathTruncated && !hasHiddenItems.value;
});
const hiddenItemActions = computed((): UserAction[] => {
return loadedHiddenItems.value.map((item) => ({
value: item.id,
label: item.label,
disabled: false,
}));
});
const getHiddenItems = async () => {
// If we already have items loaded and the source hasn't changed, use cache
if (loadedHiddenItems.value.length > 0 && props.hiddenItems === currentPromise.value) {
return;
}
// Handle synchronous array
if (Array.isArray(props.hiddenItems)) {
loadedHiddenItems.value = props.hiddenItems;
return;
}
isLoadingHiddenItems.value = true;
try {
// Store the current promise for cache comparison
currentPromise.value = props.hiddenItems;
const items = await props.hiddenItems;
loadedHiddenItems.value = items;
} catch (error) {
loadedHiddenItems.value = [];
emit('hiddenItemsLoadingError', error);
} finally {
isLoadingHiddenItems.value = false;
}
};
watch(
(): PathItem[] | Promise<PathItem[]> => props.hiddenItems,
(_newValue: PathItem[] | Promise<PathItem[]>) => {
void getHiddenItems();
},
);
const onHiddenMenuVisibleChange = async (visible: boolean) => {
if (visible) {
emit('tooltipOpened');
await getHiddenItems();
} else {
emit('tooltipClosed');
}
};
const emitItemSelected = (id: string) => {
const item = [...loadedHiddenItems.value, ...props.items].find((i) => i.id === id);
if (!item) {
return;
}
emit('itemSelected', item);
};
const handleTooltipShow = async () => {
emit('tooltipOpened');
await getHiddenItems();
};
const handleTooltipClose = () => {
emit('tooltipClosed');
};
</script>
<template>
<div
:class="{
[$style.container]: true,
[$style.border]: props.showBorder,
[$style[props.theme]]: true,
}"
>
<slot name="prepend"></slot>
<ul :class="$style.list">
<li v-if="$slots.prepend" :class="$style.separator" aria-hidden="true">{{ separator }}</li>
<li
v-if="showEllipsis"
:class="{ [$style.ellipsis]: true, [$style.disabled]: dropdownDisabled }"
data-test-id="ellipsis"
>
<!-- Show interactive dropdown for larger versions -->
<div v-if="props.theme !== 'small'" :class="$style['hidden-items-menu']">
<n8n-action-toggle
:actions="hiddenItemActions"
:loading="isLoadingHiddenItems"
:loading-row-count="loadingSkeletonRows"
:disabled="dropdownDisabled"
:class="$style['action-toggle']"
theme="dark"
placement="bottom"
size="small"
icon-orientation="horizontal"
@visible-change="onHiddenMenuVisibleChange"
@action="emitItemSelected"
>
<n8n-text :bold="true" :class="$style.dots">...</n8n-text>
</n8n-action-toggle>
</div>
<!-- Just a tooltip for smaller versions -->
<n8n-tooltip
v-else
:popper-class="$style.tooltip"
:disabled="dropdownDisabled"
trigger="click"
@before-show="handleTooltipShow"
@hide="handleTooltipClose"
>
<template #content>
<div v-if="isLoadingHiddenItems" :class="$style['tooltip-loading']">
<N8nLoading
:rows="1"
:loading="isLoadingHiddenItems"
animated
variant="p"
:shrink-last="false"
/>
</div>
<div v-else :class="$style.tooltipContent">
<div>
<n8n-text>{{ loadedHiddenItems.map((item) => item.label).join(' / ') }}</n8n-text>
</div>
</div>
</template>
<span :class="$style['tooltip-ellipsis']">...</span>
</n8n-tooltip>
</li>
<li v-if="showEllipsis" :class="$style.separator" aria-hidden="true">{{ separator }}</li>
<template v-for="(item, index) in items" :key="item.id">
<li
:class="{
[$style.item]: true,
[$style.current]: props.highlightLastItem && index === items.length - 1,
}"
data-test-id="breadcrumbs-item"
@click.prevent="emitItemSelected(item.id)"
>
<n8n-link v-if="item.href" :href="item.href" theme="text">{{ item.label }}</n8n-link>
<n8n-text v-else>{{ item.label }}</n8n-text>
</li>
<li v-if="index !== items.length - 1" :class="$style.separator" aria-hidden="true">
{{ separator }}
</li>
</template>
</ul>
<slot name="append"></slot>
</div>
</template>
<style lang="scss" module>
.container {
display: flex;
align-items: center;
gap: var(--spacing-5xs);
&.small {
display: inline-flex;
padding: var(--spacing-4xs) var(--spacing-2xs);
}
&.border {
border: var(--border-base);
border-radius: var(--border-radius-base);
}
}
.list {
display: flex;
list-style: none;
}
.item.current span {
color: var(--color-text-dark);
}
// Make disabled ellipsis look like a normal item
.ellipsis {
.dots,
.tooltip-ellipsis {
cursor: pointer;
user-select: none;
}
&.disabled {
.dots,
.tooltip-ellipsis {
cursor: default;
}
.dots {
cursor: default;
color: var(--color-text-base);
&:hover {
color: var(--color-text-base);
}
}
}
}
.hidden-items-menu {
display: flex;
position: relative;
top: var(--spacing-5xs);
color: var(--color-text-base);
}
.tooltip-loading {
min-width: var(--spacing-3xl);
width: 100%;
:global(.n8n-loading) > div {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
:global(.el-skeleton__item) {
margin: 0;
}
}
.tooltip {
padding: var(--spacing-xs) var(--spacing-2xs);
& > div {
color: var(--color-text-lighter);
span {
font-size: var(--font-size-2xs);
}
}
.tooltip-loading {
min-width: var(--spacing-4xl);
}
}
.dots {
padding: 0 var(--spacing-4xs);
color: var(--color-text-light);
border-radius: var(--border-radius-base);
&:hover,
&:focus {
background-color: var(--color-background-base);
color: var(--color-primary);
}
}
// Small theme overrides
.small {
.list {
gap: var(--spacing-5xs);
}
.item,
.item * {
color: var(--color-text-base);
font-size: var(--font-size-2xs);
font-weight: 600;
}
.item a:hover * {
color: var(--color-text-dark);
}
.separator {
font-size: var(--font-size-m);
color: var(--color-text-base);
}
}
// Medium theme overrides
.medium {
li {
padding: var(--spacing-4xs);
}
.item,
.item * {
color: var(--color-text-light);
font-size: var(--font-size-m);
}
.item a:hover * {
color: var(--color-text-base);
}
.ellipsis {
padding-right: 0;
padding-left: 0;
color: var(--color-text-light);
&:hover {
color: var(--color-text-base);
}
}
.separator {
font-size: var(--font-size-xl);
color: var(--prim-gray-670);
}
}
</style>

View file

@ -0,0 +1,93 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Breadcrumbs > does not highlight last item for "highlightLastItem = false" 1`] = `
"<div class="container medium">
<ul class="list">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
`;
exports[`Breadcrumbs > renders custom separator correctly 1`] = `
"<div class="container medium">
<ul class="list">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">➮</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">➮</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">➮</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
`;
exports[`Breadcrumbs > renders default version correctly 1`] = `
"<div class="container medium">
<ul class="list">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
`;
exports[`Breadcrumbs > renders slots correctly 1`] = `
"<div class="container medium">
<div>[PRE] Custom content</div>
<ul class="list">
<li class="separator" aria-hidden="true">/</li>
<!--v-if-->
<!--v-if-->
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
<div>[POST] Custom content</div>
</div>"
`;
exports[`Breadcrumbs > renders small version correctly 1`] = `
"<div class="container small">
<ul class="list">
<!--v-if-->
<!--v-if-->
<!--v-if-->
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item" data-test-id="breadcrumbs-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" aria-hidden="true">/</li>
<li class="item current" data-test-id="breadcrumbs-item"><span class="n8n-text size-medium regular">Current</span></li>
<!--v-if-->
</ul>
</div>"
`;

View file

@ -0,0 +1,2 @@
import Breadcrumbs from './Breadcrumbs.vue';
export default Breadcrumbs;

View file

@ -57,3 +57,4 @@ export { default as N8nUsersList } from './N8nUsersList';
export { default as N8nResizeObserver } from './ResizeObserver';
export { N8nKeyboardShortcut } from './N8nKeyboardShortcut';
export { default as N8nIconPicker } from './N8nIconPicker';
export { default as N8nBreadcrumbs } from './N8nBreadcrumbs';