mirror of
https://github.com/n8n-io/n8n.git
synced 2025-03-05 20:50:17 -08:00
feat(editor): Implement breadcrumbs component (#13317)
Co-authored-by: Rob Squires <robtf9@icloud.com>
This commit is contained in:
parent
5439181e92
commit
db297f107d
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
|
@ -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>
|
|
@ -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>"
|
||||
`;
|
|
@ -0,0 +1,2 @@
|
|||
import Breadcrumbs from './Breadcrumbs.vue';
|
||||
export default Breadcrumbs;
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue