mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-30 16:26:59 +02:00
feat(editor): In progress version of the Command bar (no-changelog) (#20198)
Co-authored-by: Jaakko Husso <jaakko@n8n.io>
This commit is contained in:
parent
071dcd836d
commit
b2a4acdabb
|
|
@ -0,0 +1,220 @@
|
|||
import type { StoryFn } from '@storybook/vue3-vite';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import N8nCommandBar from './CommandBar.vue';
|
||||
|
||||
const sampleItems = [
|
||||
// Ungrouped items (appear first)
|
||||
{
|
||||
id: 'recent-workflow-1',
|
||||
title: 'Recent: Customer Sync',
|
||||
icon: { html: '📋' },
|
||||
handler: () => console.log('Opening recent workflow'),
|
||||
},
|
||||
{
|
||||
id: 'recent-workflow-2',
|
||||
title: 'Recent: Email Campaign',
|
||||
icon: { html: '✉️' },
|
||||
handler: () => console.log('Opening recent workflow'),
|
||||
},
|
||||
|
||||
// Actions section
|
||||
{
|
||||
id: 'create-workflow',
|
||||
title: 'Create new workflow',
|
||||
icon: { html: '⚡' },
|
||||
section: 'Actions',
|
||||
handler: () => console.log('Creating new workflow'),
|
||||
},
|
||||
{
|
||||
id: 'import-workflow',
|
||||
title: 'Import workflow',
|
||||
icon: { html: '📥' },
|
||||
section: 'Actions',
|
||||
keywords: ['upload', 'file'],
|
||||
handler: () => console.log('Importing workflow'),
|
||||
},
|
||||
{
|
||||
id: 'duplicate-workflow',
|
||||
title: 'Duplicate current workflow',
|
||||
icon: { html: '📋' },
|
||||
section: 'Actions',
|
||||
handler: () => console.log('Duplicating workflow'),
|
||||
},
|
||||
|
||||
// Navigation section
|
||||
{
|
||||
id: 'workflows',
|
||||
title: 'All Workflows',
|
||||
icon: { html: '📁' },
|
||||
section: 'Navigation',
|
||||
handler: () => console.log('Opening workflows'),
|
||||
},
|
||||
{
|
||||
id: 'executions',
|
||||
title: 'Executions',
|
||||
icon: { html: '🏃' },
|
||||
section: 'Navigation',
|
||||
handler: () => console.log('Opening executions'),
|
||||
},
|
||||
{
|
||||
id: 'credentials',
|
||||
title: 'Credentials',
|
||||
icon: { html: '🔑' },
|
||||
section: 'Navigation',
|
||||
handler: () => console.log('Opening credentials'),
|
||||
},
|
||||
|
||||
// Tools section
|
||||
{
|
||||
id: 'search-nodes',
|
||||
title: 'Search nodes',
|
||||
icon: { html: '🔍' },
|
||||
keywords: ['node', 'add', 'integration'],
|
||||
section: 'Tools',
|
||||
},
|
||||
{
|
||||
id: 'test-webhook',
|
||||
title: 'Test webhook',
|
||||
icon: { html: '🌐' },
|
||||
section: 'Tools',
|
||||
handler: () => console.log('Testing webhook'),
|
||||
},
|
||||
|
||||
// Settings section
|
||||
{
|
||||
id: 'settings',
|
||||
title: 'Settings',
|
||||
icon: {
|
||||
html: '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>',
|
||||
},
|
||||
section: 'Settings',
|
||||
handler: () => console.log('Opening settings'),
|
||||
},
|
||||
{
|
||||
id: 'help',
|
||||
title: 'Help & Documentation',
|
||||
icon: { html: '❓' },
|
||||
section: 'Settings',
|
||||
handler: () => console.log('Opening help'),
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
title: 'Molecules/CommandBar',
|
||||
component: N8nCommandBar,
|
||||
argTypes: {
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
},
|
||||
context: {
|
||||
control: 'text',
|
||||
},
|
||||
items: {
|
||||
control: 'object',
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
backgrounds: { default: '--color-background-light' },
|
||||
},
|
||||
};
|
||||
|
||||
const Template: StoryFn = (args, { argTypes }) => ({
|
||||
setup: () => ({ args }),
|
||||
props: Object.keys(argTypes),
|
||||
components: {
|
||||
N8nCommandBar,
|
||||
},
|
||||
template:
|
||||
'<n8n-command-bar v-bind="args" @input-change="onInputChange" @navigate-to="onNavigateTo" @load-more="onLoadMore" />',
|
||||
methods: {
|
||||
onInputChange: action('input-change'),
|
||||
onNavigateTo: action('navigate-to'),
|
||||
onLoadMore: action('load-more'),
|
||||
},
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
placeholder: 'Type a command...',
|
||||
items: [],
|
||||
};
|
||||
|
||||
export const WithItems = Template.bind({});
|
||||
WithItems.args = {
|
||||
placeholder: 'Search commands...',
|
||||
items: sampleItems,
|
||||
};
|
||||
|
||||
export const WithContext = Template.bind({});
|
||||
WithContext.args = {
|
||||
placeholder: 'Search for anything...',
|
||||
context: 'Workflow Editor',
|
||||
items: sampleItems,
|
||||
};
|
||||
|
||||
export const CustomPlaceholder = Template.bind({});
|
||||
CustomPlaceholder.args = {
|
||||
placeholder: 'What would you like to do?',
|
||||
items: sampleItems,
|
||||
};
|
||||
|
||||
export const KeyboardShortcut: StoryFn = () => ({
|
||||
components: {
|
||||
N8nCommandBar,
|
||||
},
|
||||
data: () => ({
|
||||
items: sampleItems,
|
||||
}),
|
||||
template: `
|
||||
<div>
|
||||
<p style="margin-bottom: 20px; color: var(--color-text-base);">
|
||||
Press <kbd style="background: var(--color-background-base); padding: 2px 6px; border-radius: 3px;">⌘ + K</kbd>
|
||||
or <kbd style="background: var(--color-background-base); padding: 2px 6px; border-radius: 3px;">Ctrl + K</kbd>
|
||||
to open the command bar. Use arrow keys to navigate and Enter to select.
|
||||
</p>
|
||||
<n8n-command-bar
|
||||
placeholder="Try pressing Cmd/Ctrl + K!"
|
||||
:items="items"
|
||||
@input-change="onInputChange"
|
||||
@navigate-to="onNavigateTo"
|
||||
@load-more="onLoadMore"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
onInputChange: action('input-change'),
|
||||
onNavigateTo: action('navigate-to'),
|
||||
onLoadMore: action('load-more'),
|
||||
},
|
||||
});
|
||||
|
||||
export const SectionGrouping: StoryFn = () => ({
|
||||
components: {
|
||||
N8nCommandBar,
|
||||
},
|
||||
data: () => ({
|
||||
items: sampleItems,
|
||||
}),
|
||||
template: `
|
||||
<div>
|
||||
<p style="margin-bottom: 20px; color: var(--color-text-base);">
|
||||
This example shows how items are grouped by sections:
|
||||
<br/>• <strong>Recent items</strong> (no section) appear first
|
||||
<br/>• Then items are grouped by <strong>Actions</strong>, <strong>Navigation</strong>, <strong>Tools</strong>, and <strong>Settings</strong> sections
|
||||
</p>
|
||||
<n8n-command-bar
|
||||
placeholder="Search commands... (sections will be grouped)"
|
||||
:items="items"
|
||||
@input-change="onInputChange"
|
||||
@navigate-to="onNavigateTo"
|
||||
@load-more="onLoadMore"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
onInputChange: action('input-change'),
|
||||
onNavigateTo: action('navigate-to'),
|
||||
onLoadMore: action('load-more'),
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import N8nCommandBarItem from './CommandBarItem.vue';
|
||||
import type { CommandBarItem } from './types';
|
||||
import N8nBadge from '../N8nBadge';
|
||||
|
||||
interface CommandBarProps {
|
||||
placeholder?: string;
|
||||
context?: string;
|
||||
items: CommandBarItem[];
|
||||
}
|
||||
|
||||
defineOptions({ name: 'N8nCommandBar' });
|
||||
const props = withDefaults(defineProps<CommandBarProps>(), {
|
||||
placeholder: 'Type a command...',
|
||||
context: '',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
inputChange: [value: string];
|
||||
navigateTo: [parentId: string | null];
|
||||
loadMore: [parentId: string];
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
const selectedIndex = ref(-1);
|
||||
const inputValue = ref('');
|
||||
const currentParentId = ref<string | null>(null);
|
||||
|
||||
const currentParent = computed(() => {
|
||||
return props.items.find((item) => item.id === currentParentId.value);
|
||||
});
|
||||
|
||||
const currentItems = computed(() => {
|
||||
return currentParent.value ? (currentParent.value.children ?? []) : props.items;
|
||||
});
|
||||
|
||||
const currentPlaceholder = computed(() => {
|
||||
return currentParent.value?.placeholder ?? props.placeholder;
|
||||
});
|
||||
|
||||
const commandBarRef = ref<HTMLElement>();
|
||||
const itemsListRef = ref<HTMLElement>();
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let items = currentItems.value;
|
||||
|
||||
if (inputValue.value) {
|
||||
const query = inputValue.value.toLowerCase();
|
||||
items = items.filter((item) => {
|
||||
const searchText = [item.title, ...(item.keywords ?? [])]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
return searchText.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const groupedItems = computed(() => {
|
||||
const items = filteredItems.value;
|
||||
const ungrouped: CommandBarItem[] = [];
|
||||
const sections: Record<string, CommandBarItem[]> = {};
|
||||
|
||||
items.forEach((item) => {
|
||||
if (item.section) {
|
||||
if (!sections[item.section]) {
|
||||
sections[item.section] = [];
|
||||
}
|
||||
sections[item.section].push(item);
|
||||
} else {
|
||||
ungrouped.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ungrouped,
|
||||
sections: Object.entries(sections).map(([title, items]) => ({
|
||||
title,
|
||||
items,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const flattenedItems = computed(() => {
|
||||
const result: CommandBarItem[] = [];
|
||||
|
||||
result.push(...groupedItems.value.ungrouped);
|
||||
|
||||
groupedItems.value.sections.forEach((section) => {
|
||||
result.push(...section.items);
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
const getGlobalIndex = (item: CommandBarItem): number => {
|
||||
return flattenedItems.value.findIndex((flatItem) => flatItem.id === item.id);
|
||||
};
|
||||
|
||||
const scrollSelectedIntoView = () => {
|
||||
if (selectedIndex.value < 0) return;
|
||||
|
||||
void nextTick(() => {
|
||||
if (selectedIndex.value === 0) {
|
||||
itemsListRef.value?.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
} else if (selectedIndex.value === flattenedItems.value.length - 1) {
|
||||
itemsListRef.value?.scrollTo({
|
||||
top: itemsListRef.value.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedItem = flattenedItems.value[selectedIndex.value];
|
||||
if (!selectedItem) return;
|
||||
|
||||
const selectedElement = document.querySelector(`[data-item-id="${selectedItem.id}"]`);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openCommandBar = async () => {
|
||||
isOpen.value = true;
|
||||
selectedIndex.value = 0;
|
||||
inputValue.value = '';
|
||||
await nextTick();
|
||||
inputRef.value?.focus();
|
||||
};
|
||||
|
||||
const closeCommandBar = () => {
|
||||
isOpen.value = false;
|
||||
selectedIndex.value = -1;
|
||||
inputValue.value = '';
|
||||
currentParentId.value = null;
|
||||
};
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
if (!(event.target instanceof HTMLElement)) return;
|
||||
const target = event.target;
|
||||
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||
|
||||
if (scrollHeight - scrollTop - clientHeight < 50) {
|
||||
if (currentParent.value?.hasMoreChildren) {
|
||||
emit('loadMore', currentParent.value.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToChildren = (item: CommandBarItem) => {
|
||||
currentParentId.value = item.id;
|
||||
selectedIndex.value = 0;
|
||||
inputValue.value = '';
|
||||
|
||||
emit('navigateTo', item.id);
|
||||
};
|
||||
|
||||
const navigateBack = () => {
|
||||
if (!currentParent.value) return;
|
||||
|
||||
currentParentId.value = null;
|
||||
selectedIndex.value = 0;
|
||||
inputValue.value = '';
|
||||
|
||||
emit('navigateTo', null);
|
||||
};
|
||||
|
||||
const selectItem = (item: CommandBarItem) => {
|
||||
if (item.children) {
|
||||
navigateToChildren(item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.handler) {
|
||||
void item.handler();
|
||||
}
|
||||
|
||||
if (item.href) {
|
||||
window.location.href = item.href;
|
||||
}
|
||||
|
||||
closeCommandBar();
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
void openCommandBar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpen.value) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
void closeCommandBar();
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
selectedIndex.value = Math.min(selectedIndex.value + 1, flattenedItems.value.length - 1);
|
||||
scrollSelectedIntoView();
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
|
||||
scrollSelectedIntoView();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
if (!inputValue.value && currentParent.value) {
|
||||
event.preventDefault();
|
||||
void navigateBack();
|
||||
}
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
if (selectedIndex.value >= 0 && flattenedItems.value[selectedIndex.value]) {
|
||||
const selectedItem = flattenedItems.value[selectedIndex.value];
|
||||
if (selectedItem.children) {
|
||||
event.preventDefault();
|
||||
void navigateToChildren(selectedItem);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'Enter':
|
||||
event.preventDefault();
|
||||
if (selectedIndex.value >= 0 && flattenedItems.value[selectedIndex.value]) {
|
||||
void selectItem(flattenedItems.value[selectedIndex.value]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (!isOpen.value) return;
|
||||
|
||||
if (commandBarRef.value && !commandBarRef.value.contains(event.target as Node)) {
|
||||
closeCommandBar();
|
||||
}
|
||||
};
|
||||
|
||||
watch(inputValue, (newValue) => {
|
||||
emit('inputChange', newValue);
|
||||
selectedIndex.value = 0;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="command-bar" appear>
|
||||
<div v-if="isOpen" ref="commandBarRef" :class="$style.commandBar">
|
||||
<div v-if="context" :class="$style.contextContainer">
|
||||
<N8nBadge size="small">{{ context }}</N8nBadge>
|
||||
</div>
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="inputValue"
|
||||
:placeholder="currentPlaceholder"
|
||||
:class="$style.input"
|
||||
type="text"
|
||||
/>
|
||||
<div
|
||||
v-if="flattenedItems.length > 0"
|
||||
ref="itemsListRef"
|
||||
:class="$style.itemsList"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<template v-for="item in groupedItems.ungrouped" :key="item.id">
|
||||
<N8nCommandBarItem
|
||||
:item="item"
|
||||
:is-selected="getGlobalIndex(item) === selectedIndex"
|
||||
@select="selectItem"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="section in groupedItems.sections" :key="section.title">
|
||||
<div :class="$style.sectionHeader">{{ section.title }}</div>
|
||||
<div v-for="item in section.items" :key="item.id">
|
||||
<N8nCommandBarItem
|
||||
:item="item"
|
||||
:is-selected="getGlobalIndex(item) === selectedIndex"
|
||||
@select="selectItem"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="inputValue && flattenedItems.length === 0" :class="$style.noResults">
|
||||
No results found
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.commandBar {
|
||||
position: fixed;
|
||||
top: 20vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-background-xlight);
|
||||
border: var(--border-base);
|
||||
border-radius: var(--border-radius-large);
|
||||
box-shadow: var(--box-shadow-dark);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: var(--font-size-m);
|
||||
font-family: var(--font-family);
|
||||
color: var(--color-text-base);
|
||||
padding: var(--spacing-m) var(--spacing-l);
|
||||
border-bottom: var(--border-base);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.itemsList {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: var(--spacing-s);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
padding: var(--spacing-xs) var(--spacing-l);
|
||||
font-size: var(--font-size-2xs);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.noResults {
|
||||
padding: var(--spacing-l);
|
||||
text-align: center;
|
||||
color: var(--color-text-light);
|
||||
font-size: var(--font-size-s);
|
||||
}
|
||||
|
||||
.contextContainer {
|
||||
padding: var(--spacing-xs) var(--spacing-l) 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* Global transition classes for command bar animations */
|
||||
.command-bar-enter-active {
|
||||
transition:
|
||||
opacity 0.2s ease-out,
|
||||
transform 0.2s ease-out;
|
||||
}
|
||||
|
||||
.command-bar-leave-active {
|
||||
transition:
|
||||
opacity 0.15s ease-in,
|
||||
transform 0.15s ease-in;
|
||||
}
|
||||
|
||||
.command-bar-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-20px) scale(0.95);
|
||||
}
|
||||
|
||||
.command-bar-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px) scale(0.98);
|
||||
}
|
||||
|
||||
.command-bar-enter-to,
|
||||
.command-bar-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts" setup>
|
||||
import type { CommandBarItem } from './types';
|
||||
|
||||
const props = defineProps<{
|
||||
item: CommandBarItem;
|
||||
isSelected: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [item: CommandBarItem];
|
||||
}>();
|
||||
|
||||
const handleSelect = () => {
|
||||
emit('select', props.item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:key="item.id"
|
||||
:data-item-id="item.id"
|
||||
:class="[$style.item, { [$style.selected]: isSelected }]"
|
||||
@click.stop="handleSelect"
|
||||
>
|
||||
<div v-if="item.icon" :class="$style.icon">
|
||||
<span v-if="item.icon && 'html' in item.icon" v-n8n-html="item.icon.html"></span>
|
||||
<component
|
||||
:is="item.icon.component"
|
||||
v-else-if="item.icon && 'component' in item.icon"
|
||||
v-bind="item.icon.props"
|
||||
/>
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
<div :class="$style.title">{{ item.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.item {
|
||||
display: flex;
|
||||
gap: var(--spacing-2xs);
|
||||
align-items: center;
|
||||
height: var(--spacing-2xl);
|
||||
padding: 0 var(--spacing-s);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin-left: var(--border-width-base);
|
||||
transition: background-color 0.1s ease;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: calc(-1 * var(--border-width-base));
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: calc(2 * var(--border-width-base));
|
||||
background-color: transparent;
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-foreground-base);
|
||||
|
||||
&::before {
|
||||
background-color: var(--color-foreground-dark);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: var(--color-foreground-base);
|
||||
|
||||
&::before {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--spacing-l);
|
||||
height: var(--spacing-l);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--font-size-s);
|
||||
font-weight: var(--font-weight-regular);
|
||||
color: var(--color-text-dark);
|
||||
line-height: var(--font-line-height-compact);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-2xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import N8nCommandBar from './CommandBar.vue';
|
||||
|
||||
export default N8nCommandBar;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import type { Component } from 'vue';
|
||||
|
||||
export interface CommandBarItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: { html: string } | { component: Component; props?: Record<string, unknown> };
|
||||
section?: string;
|
||||
keywords?: string[];
|
||||
handler?: () => void | Promise<void>;
|
||||
href?: string;
|
||||
children?: CommandBarItem[];
|
||||
placeholder?: string;
|
||||
hasMoreChildren?: boolean;
|
||||
}
|
||||
|
|
@ -68,3 +68,4 @@ export { default as N8nDataTableServer } from './N8nDataTableServer';
|
|||
export { default as N8nTableHeaderControlsButton } from './TableHeaderControlsButton';
|
||||
export { default as N8nInlineTextEdit } from './N8nInlineTextEdit';
|
||||
export { default as N8nScrollArea } from './N8nScrollArea';
|
||||
export { default as N8nCommandBar } from './N8nCommandBar';
|
||||
|
|
|
|||
|
|
@ -3566,5 +3566,65 @@
|
|||
"workflowDiff.newWorkflow.remote": "The workflow will be created on remote",
|
||||
"preBuiltAgentTemplates.title": "Pre-built agents",
|
||||
"preBuiltAgentTemplates.tutorials": "Tutorial templates",
|
||||
"preBuiltAgentTemplates.viewAllLink": "View all templates"
|
||||
"preBuiltAgentTemplates.viewAllLink": "View all templates",
|
||||
"commandBar.placeholder": "Type a command...",
|
||||
"commandBar.noResults": "No results found",
|
||||
"commandBar.sections.nodes": "Nodes",
|
||||
"commandBar.sections.workflow": "Workflow",
|
||||
"commandBar.sections.workflows": "Workflows",
|
||||
"commandBar.sections.credentials": "Credentials",
|
||||
"commandBar.sections.dataTables": "Data Tables",
|
||||
"commandBar.sections.templates": "Templates",
|
||||
"commandBar.sections.demo": "Demo",
|
||||
"commandBar.nodes.addNode": "Add node",
|
||||
"commandBar.nodes.addStickyNote": "Add sticky note",
|
||||
"commandBar.nodes.openNode": "Open node",
|
||||
"commandBar.nodes.openNodeWithPrefix": "Open node > {nodeName}",
|
||||
"commandBar.nodes.addNodeWithPrefix": "Add node > {nodeName}",
|
||||
"commandBar.nodes.searchPlaceholder": "Search by node name, type, etc.",
|
||||
"commandBar.nodes.keywords.insert": "insert",
|
||||
"commandBar.nodes.keywords.add": "add",
|
||||
"commandBar.nodes.keywords.create": "create",
|
||||
"commandBar.nodes.keywords.node": "node",
|
||||
"commandBar.workflow.test": "Test workflow",
|
||||
"commandBar.workflow.save": "Save workflow",
|
||||
"commandBar.workflow.activate": "Activate workflow",
|
||||
"commandBar.workflow.deactivate": "Deactivate workflow",
|
||||
"commandBar.workflow.selectAll": "Select all",
|
||||
"commandBar.workflow.tidyUp": "Tidy up workflow",
|
||||
"commandBar.workflow.duplicate": "Duplicate workflow",
|
||||
"commandBar.workflow.download": "Download workflow",
|
||||
"commandBar.workflow.openCredential": "Open credential",
|
||||
"commandBar.workflow.openSubworkflow": "Open subworkflow",
|
||||
"commandBar.workflow.keywords.test": "test",
|
||||
"commandBar.workflow.keywords.execute": "execute",
|
||||
"commandBar.workflow.keywords.run": "run",
|
||||
"commandBar.workflow.keywords.workflow": "workflow",
|
||||
"commandBar.workflows.create": "Create workflow in {projectName}",
|
||||
"commandBar.workflows.open": "Open workflow",
|
||||
"commandBar.workflows.searchPlaceholder": "Search by workflow name or node type...",
|
||||
"commandBar.workflows.prefixPersonal": "[Personal] > ",
|
||||
"commandBar.workflows.prefixProject": "[{projectName}] > ",
|
||||
"commandBar.workflows.openPrefixPersonal": "Open workflow > [Personal] > ",
|
||||
"commandBar.workflows.openPrefixProject": "Open workflow > [{projectName}] > ",
|
||||
"commandBar.workflows.unnamed": "(unnamed workflow)",
|
||||
"commandBar.credentials.create": "Create credential in {projectName}",
|
||||
"commandBar.credentials.open": "Open credential",
|
||||
"commandBar.credentials.searchPlaceholder": "Search by credential name...",
|
||||
"commandBar.credentials.prefixPersonal": "[Personal] > ",
|
||||
"commandBar.credentials.prefixProject": "[{projectName}] > ",
|
||||
"commandBar.credentials.openPrefixPersonal": "Open credential > [Personal] > ",
|
||||
"commandBar.credentials.openPrefixProject": "Open credential > [{projectName}] > ",
|
||||
"commandBar.credentials.unnamed": "(unnamed credential)",
|
||||
"commandBar.dataTables.create": "Create data table in {projectName}",
|
||||
"commandBar.dataTables.open": "Open data table",
|
||||
"commandBar.dataTables.searchPlaceholder": "Search by data table name...",
|
||||
"commandBar.dataTables.prefixPersonal": "[Personal] > ",
|
||||
"commandBar.dataTables.prefixProject": "[{projectName}] > ",
|
||||
"commandBar.dataTables.openPrefixPersonal": "Open data table > [Personal] > ",
|
||||
"commandBar.dataTables.openPrefixProject": "Open data table > [{projectName}] > ",
|
||||
"commandBar.dataTables.unnamed": "(unnamed data table)",
|
||||
"commandBar.templates.import": "Import template",
|
||||
"commandBar.templates.importWithPrefix": "Import template > {templateName}",
|
||||
"commandBar.demo.availableEverywhere": "This is available everywhere"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { useSettingsStore } from '@/stores/settings.store';
|
|||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import LoadingView from '@/views/LoadingView.vue';
|
||||
import { locale } from '@n8n/design-system';
|
||||
import { locale, N8nCommandBar } from '@n8n/design-system';
|
||||
import { setLanguage } from '@n8n/i18n';
|
||||
// Note: no need to import en.json here; default 'en' is handled via setLanguage
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
|
@ -32,6 +32,8 @@ import { useRoute } from 'vue-router';
|
|||
import { useStyles } from './composables/useStyles';
|
||||
import { useExposeCssVar } from '@/composables/useExposeCssVar';
|
||||
import { useFloatingUiOffsets } from '@/composables/useFloatingUiOffsets';
|
||||
import { useCommandBar } from './composables/useCommandBar';
|
||||
import { hasPermission } from './utils/rbac/permissions';
|
||||
|
||||
const route = useRoute();
|
||||
const rootStore = useRootStore();
|
||||
|
|
@ -42,6 +44,18 @@ const usersStore = useUsersStore();
|
|||
const settingsStore = useSettingsStore();
|
||||
const ndvStore = useNDVStore();
|
||||
|
||||
const {
|
||||
initialize: initializeCommandBar,
|
||||
isEnabled: isCommandBarEnabled,
|
||||
items,
|
||||
onCommandBarChange,
|
||||
onCommandBarNavigateTo,
|
||||
} = useCommandBar();
|
||||
|
||||
const showCommandBar = computed(
|
||||
() => isCommandBarEnabled.value && hasPermission(['authenticated']),
|
||||
);
|
||||
|
||||
const { setAppZIndexes } = useStyles();
|
||||
const { toastBottomOffset, askAiFloatingButtonBottomOffset } = useFloatingUiOffsets();
|
||||
|
||||
|
|
@ -70,6 +84,12 @@ onMounted(async () => {
|
|||
await updateGridWidth();
|
||||
});
|
||||
|
||||
watch(showCommandBar, (newVal) => {
|
||||
if (newVal) {
|
||||
void initializeCommandBar();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateGridWidth);
|
||||
});
|
||||
|
|
@ -149,6 +169,13 @@ useExposeCssVar('--ask-assistant-floating-button-bottom-offset', askAiFloatingBu
|
|||
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
|
||||
<Modals />
|
||||
</div>
|
||||
|
||||
<N8nCommandBar
|
||||
v-if="showCommandBar"
|
||||
:items="items"
|
||||
@input-change="onCommandBarChange"
|
||||
@navigate-to="onCommandBarNavigateTo"
|
||||
/>
|
||||
<Telemetry />
|
||||
<AskAssistantFloatingButton v-if="assistantStore.isFloatingButtonShown" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -853,6 +853,7 @@ const initialized = ref(false);
|
|||
onMounted(() => {
|
||||
props.eventBus.on('fitView', onFitView);
|
||||
props.eventBus.on('nodes:select', onSelectNodes);
|
||||
props.eventBus.on('nodes:selectAll', () => addSelectedNodes(graphNodes.value));
|
||||
props.eventBus.on('tidyUp', onTidyUp);
|
||||
window.addEventListener('blur', onWindowBlur);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
import type { CommandBarItem } from '@n8n/design-system/components/N8nCommandBar/types';
|
||||
import type { ComputedRef } from 'vue';
|
||||
|
||||
export type { CommandBarItem };
|
||||
|
||||
export interface CommandBarEventHandlers {
|
||||
onCommandBarChange?: (query: string) => void;
|
||||
onCommandBarNavigateTo?: (to: string | null) => void;
|
||||
}
|
||||
|
||||
export interface CommandGroup {
|
||||
commands: ComputedRef<CommandBarItem[]>;
|
||||
handlers?: CommandBarEventHandlers;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { CommandGroup } from './types';
|
||||
|
||||
export function useBaseCommands(): CommandGroup {
|
||||
const i18n = useI18n();
|
||||
const baseCommands = computed(() => {
|
||||
return [
|
||||
{
|
||||
id: 'demo-action',
|
||||
title: i18n.baseText('commandBar.demo.availableEverywhere'),
|
||||
section: i18n.baseText('commandBar.sections.demo'),
|
||||
handler: () => {
|
||||
console.log('hello');
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
commands: baseCommands,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
import { computed, ref, type Ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { ICredentialsResponse } from '@/Interface';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import type { CommandGroup, CommandBarItem } from './types';
|
||||
|
||||
const ITEM_ID = {
|
||||
CREATE_CREDENTIAL: 'create-credential',
|
||||
OPEN_CREDENTIAL: 'open-credential',
|
||||
};
|
||||
|
||||
export function useCredentialNavigationCommands(options: {
|
||||
lastQuery: Ref<string>;
|
||||
activeNodeId: Ref<string | null>;
|
||||
currentProjectName: Ref<string>;
|
||||
}): CommandGroup {
|
||||
const i18n = useI18n();
|
||||
const { lastQuery, activeNodeId, currentProjectName } = options;
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const credentialResults = ref<ICredentialsResponse[]>([]);
|
||||
|
||||
const personalProjectId = computed(() => {
|
||||
return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
|
||||
});
|
||||
|
||||
function orderResultByCurrentProjectFirst<T extends ICredentialsResponse>(results: T[]) {
|
||||
const currentProjectId =
|
||||
typeof route.params.projectId === 'string' ? route.params.projectId : personalProjectId.value;
|
||||
return results.sort((a, b) => {
|
||||
if (a.homeProject?.id === currentProjectId) return -1;
|
||||
if (b.homeProject?.id === currentProjectId) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
const fetchCredentials = debounce(async (query: string) => {
|
||||
try {
|
||||
const trimmed = (query || '').trim();
|
||||
await credentialsStore.fetchAllCredentials();
|
||||
|
||||
const trimmedLower = trimmed.toLowerCase();
|
||||
const filtered = credentialsStore.allCredentials.filter((credential) =>
|
||||
credential.name.toLowerCase().includes(trimmedLower),
|
||||
);
|
||||
|
||||
credentialResults.value = orderResultByCurrentProjectFirst(filtered);
|
||||
} catch {
|
||||
credentialResults.value = [];
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const getCredentialTitle = (
|
||||
credential: ICredentialsResponse,
|
||||
includeOpenCredentialPrefix: boolean,
|
||||
) => {
|
||||
let prefix = '';
|
||||
if (credential.homeProject && credential.homeProject.type === 'personal') {
|
||||
prefix = includeOpenCredentialPrefix
|
||||
? i18n.baseText('commandBar.credentials.openPrefixPersonal')
|
||||
: i18n.baseText('commandBar.credentials.prefixPersonal');
|
||||
} else {
|
||||
prefix = includeOpenCredentialPrefix
|
||||
? i18n.baseText('commandBar.credentials.openPrefixProject', {
|
||||
interpolate: { projectName: credential.homeProject?.name ?? '' },
|
||||
})
|
||||
: i18n.baseText('commandBar.credentials.prefixProject', {
|
||||
interpolate: { projectName: credential.homeProject?.name ?? '' },
|
||||
});
|
||||
}
|
||||
return prefix + (credential.name || i18n.baseText('commandBar.credentials.unnamed'));
|
||||
};
|
||||
|
||||
const createCredentialCommand = (
|
||||
credential: ICredentialsResponse,
|
||||
includeOpenCredentialPrefix: boolean,
|
||||
): CommandBarItem => {
|
||||
return {
|
||||
id: credential.id,
|
||||
title: getCredentialTitle(credential, includeOpenCredentialPrefix),
|
||||
section: i18n.baseText('commandBar.sections.credentials'),
|
||||
handler: () => {
|
||||
uiStore.openExistingCredential(credential.id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const openCredentialCommands = computed<CommandBarItem[]>(() => {
|
||||
return credentialResults.value.map((credential) => createCredentialCommand(credential, false));
|
||||
});
|
||||
|
||||
const rootCredentialItems = computed<CommandBarItem[]>(() => {
|
||||
if (lastQuery.value.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
return credentialResults.value.map((credential) => createCredentialCommand(credential, true));
|
||||
});
|
||||
|
||||
const credentialNavigationCommands = computed<CommandBarItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: ITEM_ID.CREATE_CREDENTIAL,
|
||||
title: i18n.baseText('commandBar.credentials.create', {
|
||||
interpolate: { projectName: currentProjectName.value },
|
||||
}),
|
||||
section: i18n.baseText('commandBar.sections.credentials'),
|
||||
icon: {
|
||||
component: N8nIcon,
|
||||
props: {
|
||||
icon: 'plus',
|
||||
},
|
||||
},
|
||||
handler: () => {
|
||||
void router.push({
|
||||
params: { credentialId: 'create' },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ITEM_ID.OPEN_CREDENTIAL,
|
||||
title: i18n.baseText('commandBar.credentials.open'),
|
||||
section: i18n.baseText('commandBar.sections.credentials'),
|
||||
placeholder: i18n.baseText('commandBar.credentials.searchPlaceholder'),
|
||||
children: openCredentialCommands.value,
|
||||
icon: {
|
||||
component: N8nIcon,
|
||||
props: {
|
||||
icon: 'arrow-right',
|
||||
},
|
||||
},
|
||||
},
|
||||
...rootCredentialItems.value,
|
||||
];
|
||||
});
|
||||
|
||||
function onCommandBarChange(query: string) {
|
||||
lastQuery.value = query;
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length > 2 || activeNodeId.value === ITEM_ID.OPEN_CREDENTIAL) {
|
||||
void fetchCredentials(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function onCommandBarNavigateTo(to: string | null) {
|
||||
activeNodeId.value = to;
|
||||
|
||||
if (to === ITEM_ID.OPEN_CREDENTIAL) {
|
||||
void fetchCredentials('');
|
||||
} else if (to === null) {
|
||||
credentialResults.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commands: credentialNavigationCommands,
|
||||
handlers: {
|
||||
onCommandBarChange,
|
||||
onCommandBarNavigateTo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { computed, ref, type Ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { DATA_STORE_DETAILS, PROJECT_DATA_STORES } from '@/features/dataStore/constants';
|
||||
import type { CommandGroup, CommandBarItem } from './types';
|
||||
import type { DataStore } from '@/features/dataStore/datastore.types';
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
|
||||
const ITEM_ID = {
|
||||
OPEN_DATA_TABLE: 'open-data-table',
|
||||
CREATE_DATA_TABLE: 'create-data-table',
|
||||
};
|
||||
|
||||
export function useDataTableNavigationCommands(options: {
|
||||
lastQuery: Ref<string>;
|
||||
activeNodeId: Ref<string | null>;
|
||||
currentProjectName: Ref<string>;
|
||||
}): CommandGroup {
|
||||
const i18n = useI18n();
|
||||
const { lastQuery, activeNodeId, currentProjectName } = options;
|
||||
const dataStoreStore = useDataStoreStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const dataTableResults = ref<DataStore[]>([]);
|
||||
|
||||
const currentProjectId = computed(() => {
|
||||
return typeof route.params.projectId === 'string'
|
||||
? route.params.projectId
|
||||
: personalProjectId.value;
|
||||
});
|
||||
|
||||
const personalProjectId = computed(() => {
|
||||
return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
|
||||
});
|
||||
|
||||
function orderResultByCurrentProjectFirst<T extends DataStore>(results: T[]) {
|
||||
return results.sort((a, b) => {
|
||||
if (a.project?.id === currentProjectId.value) return -1;
|
||||
if (b.project?.id === currentProjectId.value) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
const fetchDataTables = debounce(async (query: string) => {
|
||||
try {
|
||||
const trimmed = (query || '').trim();
|
||||
|
||||
if (!currentProjectId.value) {
|
||||
dataTableResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
await dataStoreStore.fetchDataStores(
|
||||
currentProjectId.value,
|
||||
1,
|
||||
100, // TODO: pagination/lazy loading
|
||||
);
|
||||
|
||||
const trimmedLower = trimmed.toLowerCase();
|
||||
const filtered = dataStoreStore.dataStores.filter((dataTable) =>
|
||||
dataTable.name.toLowerCase().includes(trimmedLower),
|
||||
);
|
||||
|
||||
dataTableResults.value = orderResultByCurrentProjectFirst(filtered);
|
||||
} catch {
|
||||
dataTableResults.value = [];
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const getDataTableTitle = (dataTable: DataStore, includeOpenDataTablePrefix: boolean) => {
|
||||
let prefix = '';
|
||||
if (dataTable.project && dataTable.project.type === 'personal') {
|
||||
prefix = includeOpenDataTablePrefix
|
||||
? i18n.baseText('commandBar.dataTables.openPrefixPersonal')
|
||||
: i18n.baseText('commandBar.dataTables.prefixPersonal');
|
||||
} else {
|
||||
prefix = includeOpenDataTablePrefix
|
||||
? i18n.baseText('commandBar.dataTables.openPrefixProject', {
|
||||
interpolate: { projectName: dataTable.project?.name ?? '' },
|
||||
})
|
||||
: i18n.baseText('commandBar.dataTables.prefixProject', {
|
||||
interpolate: { projectName: dataTable.project?.name ?? '' },
|
||||
});
|
||||
}
|
||||
return prefix + (dataTable.name || i18n.baseText('commandBar.dataTables.unnamed'));
|
||||
};
|
||||
|
||||
const createDataTableCommand = (
|
||||
dataTable: DataStore,
|
||||
includeOpenDataTablePrefix: boolean,
|
||||
): CommandBarItem => {
|
||||
return {
|
||||
id: dataTable.id,
|
||||
title: getDataTableTitle(dataTable, includeOpenDataTablePrefix),
|
||||
section: i18n.baseText('commandBar.sections.dataTables'),
|
||||
handler: () => {
|
||||
const targetRoute = router.resolve({
|
||||
name: DATA_STORE_DETAILS,
|
||||
params: {
|
||||
projectId: dataTable.projectId,
|
||||
id: dataTable.id,
|
||||
},
|
||||
});
|
||||
window.location.href = targetRoute.fullPath;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const openDataTableCommands = computed<CommandBarItem[]>(() => {
|
||||
return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, false));
|
||||
});
|
||||
|
||||
const rootDataTableItems = computed<CommandBarItem[]>(() => {
|
||||
if (lastQuery.value.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, true));
|
||||
});
|
||||
|
||||
const dataTableNavigationCommands = computed<CommandBarItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: ITEM_ID.CREATE_DATA_TABLE,
|
||||
title: i18n.baseText('commandBar.dataTables.create', {
|
||||
interpolate: { projectName: currentProjectName.value },
|
||||
}),
|
||||
section: i18n.baseText('commandBar.sections.dataTables'),
|
||||
icon: {
|
||||
component: N8nIcon,
|
||||
props: {
|
||||
icon: 'plus',
|
||||
},
|
||||
},
|
||||
handler: () => {
|
||||
if (!currentProjectId.value) return;
|
||||
void router.push({
|
||||
name: PROJECT_DATA_STORES,
|
||||
params: { projectId: currentProjectId.value, new: 'new' },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ITEM_ID.OPEN_DATA_TABLE,
|
||||
title: i18n.baseText('commandBar.dataTables.open'),
|
||||
section: i18n.baseText('commandBar.sections.dataTables'),
|
||||
placeholder: i18n.baseText('commandBar.dataTables.searchPlaceholder'),
|
||||
icon: {
|
||||
component: N8nIcon,
|
||||
props: {
|
||||
icon: 'arrow-right',
|
||||
},
|
||||
},
|
||||
children: openDataTableCommands.value,
|
||||
},
|
||||
...rootDataTableItems.value,
|
||||
];
|
||||
});
|
||||
|
||||
function onCommandBarChange(query: string) {
|
||||
lastQuery.value = query;
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length > 2 || activeNodeId.value === ITEM_ID.OPEN_DATA_TABLE) {
|
||||
void fetchDataTables(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function onCommandBarNavigateTo(to: string | null) {
|
||||
activeNodeId.value = to;
|
||||
|
||||
if (to === ITEM_ID.OPEN_DATA_TABLE) {
|
||||
void fetchDataTables('');
|
||||
} else if (to === null) {
|
||||
dataTableResults.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commands: dataTableNavigationCommands,
|
||||
handlers: {
|
||||
onCommandBarChange,
|
||||
onCommandBarNavigateTo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { useActionsGenerator } from '@/components/Node/NodeCreator/composables/useActionsGeneration';
|
||||
import { canvasEventBus } from '@/event-bus/canvas';
|
||||
import { type CommandBarItem } from '@n8n/design-system/components/N8nCommandBar/types';
|
||||
import { getNodeIcon, getNodeIconUrl } from '@/utils/nodeIcon';
|
||||
import type { SimplifiedNodeType } from '@/Interface';
|
||||
import type { CommandGroup } from './types';
|
||||
|
||||
function getIconSource(nodeType: SimplifiedNodeType | null, baseUrl: string) {
|
||||
if (!nodeType) return {};
|
||||
const iconUrl = getNodeIconUrl(nodeType);
|
||||
if (iconUrl) {
|
||||
return { path: baseUrl + iconUrl };
|
||||
}
|
||||
// Otherwise, extract it from icon prop
|
||||
if (nodeType.icon) {
|
||||
const icon = getNodeIcon(nodeType);
|
||||
if (icon) {
|
||||
const [type, path] = icon.split(':');
|
||||
if (type === 'file') {
|
||||
return {};
|
||||
}
|
||||
return { icon: path };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export function useNodeCommands(): CommandGroup {
|
||||
const i18n = useI18n();
|
||||
const { addNodes, setNodeActive, editableWorkflow } = useCanvasOperations();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const rootStore = useRootStore();
|
||||
const { generateMergedNodesAndActions } = useActionsGenerator();
|
||||
|
||||
const addNodeCommands = computed<CommandBarItem[]>(() => {
|
||||
const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
|
||||
const nodeTypes = nodeTypesStore.visibleNodeTypes;
|
||||
const { mergedNodes } = generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials);
|
||||
return mergedNodes.map((node) => {
|
||||
const { name, displayName } = node;
|
||||
const src = getIconSource(node, rootStore.baseUrl);
|
||||
return {
|
||||
id: name,
|
||||
title: i18n.baseText('commandBar.nodes.addNodeWithPrefix', {
|
||||
interpolate: { nodeName: displayName },
|
||||
}),
|
||||
keywords: [
|
||||
i18n.baseText('commandBar.nodes.keywords.insert'),
|
||||
i18n.baseText('commandBar.nodes.keywords.add'),
|
||||
i18n.baseText('commandBar.nodes.keywords.create'),
|
||||
i18n.baseText('commandBar.nodes.keywords.node'),
|
||||
],
|
||||
icon: src.path
|
||||
? {
|
||||
html: `<img src="${src.path}" style="width: 24px;object-fit: contain;height: 24px;" />`,
|
||||
}
|
||||
: undefined,
|
||||
handler: async () => {
|
||||
await addNodes([{ type: name }]);
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const openNodeCommands = computed<CommandBarItem[]>(() => {
|
||||
return editableWorkflow.value.nodes.map((node) => {
|
||||
const { id, name, type } = node;
|
||||
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
|
||||
const src = getIconSource(nodeType, rootStore.baseUrl);
|
||||
return {
|
||||
id,
|
||||
title: i18n.baseText('commandBar.nodes.openNodeWithPrefix', {
|
||||
interpolate: { nodeName: name },
|
||||
}),
|
||||
section: i18n.baseText('commandBar.sections.nodes'),
|
||||
keywords: [type],
|
||||
icon: src?.path
|
||||
? {
|
||||
html: `<img src="${src.path}" style="width: 24px;object-fit: contain;height: 24px;" />`,
|
||||
}
|
||||
: undefined,
|
||||
handler: () => {
|
||||
setNodeActive(id, 'command_bar');
|
||||
},
|
||||
placeholder: i18n.baseText('commandBar.nodes.searchPlaceholder'),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const nodeCommands = computed<CommandBarItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'add-node',
|
||||
title: i18n.baseText('commandBar.nodes.addNode'),
|
||||
section: i18n.baseText('commandBar.sections.nodes'),
|
||||
children: [...addNodeCommands.value],
|
||||
},
|
||||
{
|
||||
id: 'add-sticky-note',
|
||||
title: i18n.baseText('commandBar.nodes.addStickyNote'),
|
||||
section: i18n.baseText('commandBar.sections.nodes'),
|
||||
handler: () => {
|
||||
canvasEventBus.emit('create:sticky');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-node',
|
||||
title: i18n.baseText('commandBar.nodes.openNode'),
|
||||
section: i18n.baseText('commandBar.sections.nodes'),
|
||||
children: [...openNodeCommands.value],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
commands: nodeCommands,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useTemplatesStore } from '@/stores/templates.store';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import type { CommandGroup } from './types';
|
||||
|
||||
export function useTemplateCommands(): CommandGroup {
|
||||
const i18n = useI18n();
|
||||
const { openWorkflowTemplate } = useCanvasOperations();
|
||||
const templatesStore = useTemplatesStore();
|
||||
|
||||
const importTemplateCommands = computed(() => {
|
||||
const templateWorkflows = Object.values(templatesStore.workflows);
|
||||
return templateWorkflows.map((template) => {
|
||||
const { id, name } = template;
|
||||
return {
|
||||
id: id.toString(),
|
||||
title: i18n.baseText('commandBar.templates.importWithPrefix', {
|
||||
interpolate: { templateName: name },
|
||||
}),
|
||||
section: i18n.baseText('commandBar.sections.templates'),
|
||||
handler: async () => {
|
||||
await openWorkflowTemplate(id.toString());
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const templateCommands = computed(() => {
|
||||
return [
|
||||
{
|
||||
id: 'import-template',
|
||||
title: i18n.baseText('commandBar.templates.import'),
|
||||
children: [...importTemplateCommands.value],
|
||||
section: i18n.baseText('commandBar.sections.templates'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
commands: templateCommands,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { isResourceLocatorValue } from 'n8n-workflow';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useCanvasOperations } from '@/composables/useCanvasOperations';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
|
||||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { canvasEventBus } from '@/event-bus/canvas';
|
||||
import { DUPLICATE_MODAL_KEY, EXECUTE_WORKFLOW_NODE_TYPE, VIEWS } from '@/constants';
|
||||
import type { IWorkflowToShare } from '@/Interface';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useWorkflowActivate } from '../useWorkflowActivate';
|
||||
import type { CommandGroup, CommandBarItem } from './types';
|
||||
import uniqBy from 'lodash/uniqBy';
|
||||
|
||||
export function useWorkflowCommands(): CommandGroup {
|
||||
const i18n = useI18n();
|
||||
const { editableWorkflow } = useCanvasOperations();
|
||||
const rootStore = useRootStore();
|
||||
const uiStore = useUIStore();
|
||||
const tagsStore = useTagsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const workflowHelpers = useWorkflowHelpers();
|
||||
const telemetry = useTelemetry();
|
||||
const workflowSaving = useWorkflowSaving({ router });
|
||||
const workflowActivate = useWorkflowActivate();
|
||||
|
||||
const credentialCommands = computed<CommandBarItem[]>(() => {
|
||||
const credentials = uniqBy(
|
||||
editableWorkflow.value.nodes.map((node) => Object.values(node.credentials ?? {})).flat(),
|
||||
(cred) => cred.id,
|
||||
);
|
||||
if (credentials.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'open-credential',
|
||||
title: i18n.baseText('commandBar.workflow.openCredential'),
|
||||
section: i18n.baseText('commandBar.sections.credentials'),
|
||||
children: [
|
||||
...credentials.map((credential) => ({
|
||||
id: credential.id as string,
|
||||
title: credential.name,
|
||||
handler: () => {
|
||||
if (typeof credential.id === 'string') {
|
||||
uiStore.openExistingCredential(credential.id);
|
||||
}
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const subworkflowCommands = computed<CommandBarItem[]>(() => {
|
||||
const subworkflows = editableWorkflow.value.nodes
|
||||
.filter((node) => node.type === EXECUTE_WORKFLOW_NODE_TYPE)
|
||||
.map((node) => node?.parameters?.workflowId)
|
||||
.filter(
|
||||
(rlValue): rlValue is { value: string; cachedResultName: string } =>
|
||||
isResourceLocatorValue(rlValue) &&
|
||||
typeof rlValue.value === 'string' &&
|
||||
typeof rlValue.cachedResultName === 'string',
|
||||
)
|
||||
.map(({ value, cachedResultName }) => ({ id: value, name: cachedResultName }));
|
||||
if (subworkflows.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: 'open-sub-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.openSubworkflow'),
|
||||
children: [
|
||||
...subworkflows.map((workflow) => ({
|
||||
id: workflow.id,
|
||||
title: workflow.name,
|
||||
handler: () => {
|
||||
const { href } = router.resolve({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: workflow.id },
|
||||
});
|
||||
window.open(href, '_blank', 'noreferrer');
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const workflowCommands = computed<CommandBarItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'test-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.test'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
keywords: [
|
||||
i18n.baseText('commandBar.workflow.keywords.test'),
|
||||
i18n.baseText('commandBar.workflow.keywords.execute'),
|
||||
i18n.baseText('commandBar.workflow.keywords.run'),
|
||||
i18n.baseText('commandBar.workflow.keywords.workflow'),
|
||||
],
|
||||
handler: () => {
|
||||
// Lazily instantiate useRunWorkflow only when the handler runs to avoid early initialization side effects
|
||||
void useRunWorkflow({ router }).runEntireWorkflow('main');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'save-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.save'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
handler: async () => {
|
||||
const saved = await workflowSaving.saveCurrentWorkflow();
|
||||
if (saved) {
|
||||
canvasEventBus.emit('saved:workflow');
|
||||
}
|
||||
},
|
||||
},
|
||||
...(workflowsStore.isWorkflowActive
|
||||
? [
|
||||
{
|
||||
id: 'deactivate-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.deactivate'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
handler: () => {
|
||||
void workflowActivate.updateWorkflowActivation(workflowsStore.workflowId, false);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: 'activate-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.activate'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
handler: () => {
|
||||
void workflowActivate.updateWorkflowActivation(workflowsStore.workflowId, true);
|
||||
},
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: 'select-all',
|
||||
title: i18n.baseText('commandBar.workflow.selectAll'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
handler: () => {
|
||||
canvasEventBus.emit('nodes:selectAll');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'tidy-up-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.tidyUp'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
handler: () => {
|
||||
canvasEventBus.emit('tidyUp', {
|
||||
source: 'command-bar',
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.duplicate'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
handler: () => {
|
||||
uiStore.openModalWithData({
|
||||
name: DUPLICATE_MODAL_KEY,
|
||||
data: {
|
||||
id: workflowsStore.workflowId,
|
||||
name: editableWorkflow.value.name,
|
||||
tags: editableWorkflow.value.tags,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'download-workflow',
|
||||
title: i18n.baseText('commandBar.workflow.download'),
|
||||
section: i18n.baseText('commandBar.sections.workflow'),
|
||||
handler: async () => {
|
||||
const workflowData = await workflowHelpers.getWorkflowDataToSave();
|
||||
const { tags, ...data } = workflowData;
|
||||
const exportData: IWorkflowToShare = {
|
||||
...data,
|
||||
meta: {
|
||||
...workflowData.meta,
|
||||
instanceId: rootStore.instanceId,
|
||||
},
|
||||
tags: (tags ?? []).map((tagId) => {
|
||||
return tagsStore.tagsById[tagId];
|
||||
}),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
let name = editableWorkflow.value.name || 'unsaved_workflow';
|
||||
name = name.replace(/[^a-z0-9]/gi, '_');
|
||||
telemetry.track('User exported workflow', { workflow_id: workflowData.id });
|
||||
saveAs(blob, name + '.json');
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const allCommands = computed(() => [
|
||||
...workflowCommands.value,
|
||||
...credentialCommands.value,
|
||||
...subworkflowCommands.value,
|
||||
]);
|
||||
|
||||
return {
|
||||
commands: allCommands,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
import { computed, ref, type Ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { N8nIcon } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useCredentialsStore } from '@/stores/credentials.store';
|
||||
import { useActionsGenerator } from '@/components/Node/NodeCreator/composables/useActionsGeneration';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { VIEWS } from '@/constants';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import type { CommandGroup, CommandBarItem } from './types';
|
||||
|
||||
const ITEM_ID = {
|
||||
CREATE_WORKFLOW: 'create-workflow',
|
||||
OPEN_WORKFLOW: 'open-workflow',
|
||||
};
|
||||
|
||||
export function useWorkflowNavigationCommands(options: {
|
||||
lastQuery: Ref<string>;
|
||||
activeNodeId: Ref<string | null>;
|
||||
currentProjectName: Ref<string>;
|
||||
}): CommandGroup {
|
||||
const i18n = useI18n();
|
||||
const { lastQuery, activeNodeId, currentProjectName } = options;
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const credentialsStore = useCredentialsStore();
|
||||
const workflowsStore = useWorkflowsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const { generateMergedNodesAndActions } = useActionsGenerator();
|
||||
|
||||
const workflowResults = ref<IWorkflowDb[]>([]);
|
||||
|
||||
const personalProjectId = computed(() => {
|
||||
return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
|
||||
});
|
||||
|
||||
function orderResultByCurrentProjectFirst<T extends IWorkflowDb>(results: T[]) {
|
||||
const currentProjectId =
|
||||
typeof route.params.projectId === 'string' ? route.params.projectId : personalProjectId.value;
|
||||
return results.sort((a, b) => {
|
||||
if (a.homeProject?.id === currentProjectId) return -1;
|
||||
if (b.homeProject?.id === currentProjectId) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
const fetchWorkflows = debounce(async (query: string) => {
|
||||
try {
|
||||
const trimmed = (query || '').trim();
|
||||
const nameSearchPromise = workflowsStore.searchWorkflows({
|
||||
name: trimmed,
|
||||
});
|
||||
|
||||
// Find matching node types from available nodes
|
||||
const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
|
||||
const visibleNodeTypes = nodeTypesStore.allNodeTypes;
|
||||
const { mergedNodes } = generateMergedNodesAndActions(visibleNodeTypes, httpOnlyCredentials);
|
||||
const trimmedLower = trimmed.toLowerCase();
|
||||
const matchedNodeTypeNames = Array.from(
|
||||
new Set(
|
||||
mergedNodes
|
||||
.filter(
|
||||
(node) =>
|
||||
node.displayName?.toLowerCase().includes(trimmedLower) ||
|
||||
node.name?.toLowerCase().includes(trimmedLower),
|
||||
)
|
||||
.map((node) => node.name),
|
||||
),
|
||||
);
|
||||
|
||||
const nodeTypeSearchPromise =
|
||||
matchedNodeTypeNames.length > 0
|
||||
? workflowsStore.searchWorkflows({
|
||||
// nodeTypes: matchedNodeTypeNames, TODO
|
||||
})
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [byName, byNodeTypes] = await Promise.all([nameSearchPromise, nodeTypeSearchPromise]);
|
||||
|
||||
// Merge and dedupe by id
|
||||
const merged = [...byName, ...byNodeTypes];
|
||||
const uniqueById = Array.from(new Map(merged.map((w) => [w.id, w])).values());
|
||||
workflowResults.value = orderResultByCurrentProjectFirst(uniqueById);
|
||||
} catch {
|
||||
workflowResults.value = [];
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const getWorkflowTitle = (workflow: IWorkflowDb, includeOpenWorkflowPrefix: boolean) => {
|
||||
let prefix = '';
|
||||
if (workflow.homeProject && workflow.homeProject.type === 'personal') {
|
||||
prefix = includeOpenWorkflowPrefix
|
||||
? i18n.baseText('commandBar.workflows.openPrefixPersonal')
|
||||
: i18n.baseText('commandBar.workflows.prefixPersonal');
|
||||
} else {
|
||||
prefix = includeOpenWorkflowPrefix
|
||||
? i18n.baseText('commandBar.workflows.openPrefixProject', {
|
||||
interpolate: { projectName: workflow.homeProject?.name ?? '' },
|
||||
})
|
||||
: i18n.baseText('commandBar.workflows.prefixProject', {
|
||||
interpolate: { projectName: workflow.homeProject?.name ?? '' },
|
||||
});
|
||||
}
|
||||
return prefix + (workflow.name || i18n.baseText('commandBar.workflows.unnamed'));
|
||||
};
|
||||
|
||||
const createWorkflowCommand = (
|
||||
workflow: IWorkflowDb,
|
||||
includeOpenWorkflowPrefix: boolean,
|
||||
): CommandBarItem => {
|
||||
return {
|
||||
id: workflow.id,
|
||||
title: getWorkflowTitle(workflow, includeOpenWorkflowPrefix),
|
||||
section: i18n.baseText('commandBar.sections.workflows'),
|
||||
handler: () => {
|
||||
const targetRoute = router.resolve({
|
||||
name: VIEWS.WORKFLOW,
|
||||
params: { name: workflow.id },
|
||||
});
|
||||
window.location.href = targetRoute.fullPath;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const openWorkflowCommands = computed<CommandBarItem[]>(() => {
|
||||
return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, false));
|
||||
});
|
||||
|
||||
const rootWorkflowItems = computed<CommandBarItem[]>(() => {
|
||||
if (lastQuery.value.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, true));
|
||||
});
|
||||
|
||||
const workflowNavigationCommands = computed<CommandBarItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: ITEM_ID.CREATE_WORKFLOW,
|
||||
title: i18n.baseText('commandBar.workflows.create', {
|
||||
interpolate: { projectName: currentProjectName.value },
|
||||
}),
|
||||
section: i18n.baseText('commandBar.sections.workflows'),
|
||||
icon: {
|
||||
component: N8nIcon,
|
||||
props: {
|
||||
icon: 'plus',
|
||||
},
|
||||
},
|
||||
handler: () => {
|
||||
void router.push({
|
||||
name: VIEWS.NEW_WORKFLOW,
|
||||
query: {
|
||||
projectId: route.params.projectId,
|
||||
parentFolderId: route.params.folderId,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: ITEM_ID.OPEN_WORKFLOW,
|
||||
title: i18n.baseText('commandBar.workflows.open'),
|
||||
section: i18n.baseText('commandBar.sections.workflows'),
|
||||
placeholder: i18n.baseText('commandBar.workflows.searchPlaceholder'),
|
||||
children: openWorkflowCommands.value,
|
||||
icon: {
|
||||
component: N8nIcon,
|
||||
props: {
|
||||
icon: 'arrow-right',
|
||||
},
|
||||
},
|
||||
},
|
||||
...rootWorkflowItems.value,
|
||||
];
|
||||
});
|
||||
|
||||
function onCommandBarChange(query: string) {
|
||||
lastQuery.value = query;
|
||||
|
||||
const trimmed = query.trim();
|
||||
if (trimmed.length > 2 || activeNodeId.value === ITEM_ID.OPEN_WORKFLOW) {
|
||||
void fetchWorkflows(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
function onCommandBarNavigateTo(to: string | null) {
|
||||
activeNodeId.value = to;
|
||||
|
||||
if (to === ITEM_ID.OPEN_WORKFLOW) {
|
||||
void fetchWorkflows('');
|
||||
} else if (to === null) {
|
||||
workflowResults.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
commands: workflowNavigationCommands,
|
||||
handlers: {
|
||||
onCommandBarChange,
|
||||
onCommandBarNavigateTo,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ export type CanvasLayoutSource =
|
|||
| 'keyboard-shortcut'
|
||||
| 'canvas-button'
|
||||
| 'context-menu'
|
||||
| 'command-bar'
|
||||
| 'import-workflow-data';
|
||||
export type CanvasLayoutTargetData = {
|
||||
nodes: Array<GraphNode<CanvasNodeData>>;
|
||||
|
|
|
|||
132
packages/frontend/editor-ui/src/composables/useCommandBar.ts
Normal file
132
packages/frontend/editor-ui/src/composables/useCommandBar.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import { computed, ref } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { COMMAND_BAR_EXPERIMENT, VIEWS } from '@/constants';
|
||||
import { type CommandBarItem } from '@n8n/design-system/components/N8nCommandBar/types';
|
||||
import { useNodeCommands } from './commandBar/useNodeCommands';
|
||||
import { useWorkflowCommands } from './commandBar/useWorkflowCommands';
|
||||
import { useWorkflowNavigationCommands } from './commandBar/useWorkflowNavigationCommands';
|
||||
import { useTemplateCommands } from './commandBar/useTemplateCommands';
|
||||
import { useBaseCommands } from './commandBar/useBaseCommands';
|
||||
import { useDataTableNavigationCommands } from './commandBar/useDataTableNavigationCommands';
|
||||
import { useCredentialNavigationCommands } from './commandBar/useCredentialNavigationCommands';
|
||||
import type { CommandGroup } from './commandBar/types';
|
||||
import { usePostHog } from '@/stores/posthog.store';
|
||||
|
||||
export function useCommandBar() {
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const postHog = usePostHog();
|
||||
|
||||
const isEnabled = computed(() =>
|
||||
postHog.isVariantEnabled(COMMAND_BAR_EXPERIMENT.name, COMMAND_BAR_EXPERIMENT.variant),
|
||||
);
|
||||
|
||||
const activeNodeId = ref<string | null>(null);
|
||||
const lastQuery = ref('');
|
||||
|
||||
const personalProjectId = computed(() => {
|
||||
return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
|
||||
});
|
||||
|
||||
const currentProjectName = computed(() => {
|
||||
if (!route.params.projectId || route.params.projectId === personalProjectId.value) {
|
||||
return 'Personal';
|
||||
}
|
||||
return (
|
||||
projectsStore.myProjects.find((p) => p.id === route.params.projectId)?.name ?? 'Personal'
|
||||
);
|
||||
});
|
||||
|
||||
const baseCommandGroup = useBaseCommands();
|
||||
const nodeCommandGroup = useNodeCommands();
|
||||
const workflowCommandGroup = useWorkflowCommands();
|
||||
const workflowNavigationGroup = useWorkflowNavigationCommands({
|
||||
lastQuery,
|
||||
activeNodeId,
|
||||
currentProjectName,
|
||||
});
|
||||
const templateCommandGroup = useTemplateCommands();
|
||||
const dataTableNavigationGroup = useDataTableNavigationCommands({
|
||||
lastQuery,
|
||||
activeNodeId,
|
||||
currentProjectName,
|
||||
});
|
||||
const credentialNavigationGroup = useCredentialNavigationCommands({
|
||||
lastQuery,
|
||||
activeNodeId,
|
||||
currentProjectName,
|
||||
});
|
||||
|
||||
const canvasViewGroups: CommandGroup[] = [
|
||||
baseCommandGroup,
|
||||
nodeCommandGroup,
|
||||
workflowCommandGroup,
|
||||
templateCommandGroup,
|
||||
];
|
||||
|
||||
const workflowsListViewGroups: CommandGroup[] = [
|
||||
baseCommandGroup,
|
||||
workflowNavigationGroup,
|
||||
dataTableNavigationGroup,
|
||||
];
|
||||
|
||||
const credentialsListViewGroups: CommandGroup[] = [
|
||||
credentialNavigationGroup,
|
||||
workflowNavigationGroup,
|
||||
dataTableNavigationGroup,
|
||||
baseCommandGroup,
|
||||
];
|
||||
|
||||
const activeCommandGroups = computed<CommandGroup[]>(() => {
|
||||
if (router.currentRoute.value.name === VIEWS.WORKFLOW) {
|
||||
return canvasViewGroups;
|
||||
} else if (
|
||||
router.currentRoute.value.name === VIEWS.WORKFLOWS ||
|
||||
router.currentRoute.value.name === VIEWS.PROJECTS_WORKFLOWS
|
||||
) {
|
||||
return workflowsListViewGroups;
|
||||
} else if (
|
||||
router.currentRoute.value.name === VIEWS.CREDENTIALS ||
|
||||
router.currentRoute.value.name === VIEWS.PROJECTS_CREDENTIALS
|
||||
) {
|
||||
return credentialsListViewGroups;
|
||||
}
|
||||
return [baseCommandGroup];
|
||||
});
|
||||
|
||||
const items = computed<CommandBarItem[]>(() => {
|
||||
return activeCommandGroups.value.flatMap((group) => group.commands.value);
|
||||
});
|
||||
|
||||
function onCommandBarChange(query: string) {
|
||||
for (const group of activeCommandGroups.value) {
|
||||
if (group.handlers?.onCommandBarChange) {
|
||||
group.handlers.onCommandBarChange(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onCommandBarNavigateTo(to: string | null) {
|
||||
for (const group of activeCommandGroups.value) {
|
||||
if (group.handlers?.onCommandBarNavigateTo) {
|
||||
group.handlers.onCommandBarNavigateTo(to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
}
|
||||
|
||||
return {
|
||||
isEnabled,
|
||||
items,
|
||||
initialize,
|
||||
onCommandBarChange,
|
||||
onCommandBarNavigateTo,
|
||||
};
|
||||
}
|
||||
|
|
@ -779,6 +779,12 @@ export const NDV_IN_FOCUS_PANEL_EXPERIMENT = {
|
|||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const COMMAND_BAR_EXPERIMENT = {
|
||||
name: 'command_bar',
|
||||
control: 'control',
|
||||
variant: 'variant',
|
||||
};
|
||||
|
||||
export const NDV_UI_OVERHAUL_EXPERIMENT = {
|
||||
name: '029_ndv_ui_overhaul',
|
||||
control: 'control',
|
||||
|
|
|
|||
|
|
@ -179,12 +179,14 @@ export type CanvasEventBusEvents = {
|
|||
'saved:workflow': never;
|
||||
'open:execution': IExecutionResponse;
|
||||
'nodes:select': { ids: string[]; panIntoView?: boolean };
|
||||
'nodes:selectAll': never;
|
||||
'nodes:action': {
|
||||
ids: string[];
|
||||
action: keyof CanvasNodeEventBusEvents;
|
||||
payload?: CanvasNodeEventBusEvents[keyof CanvasNodeEventBusEvents];
|
||||
};
|
||||
tidyUp: { source: CanvasLayoutSource; nodeIdsFilter?: string[]; trackEvents?: boolean };
|
||||
'create:sticky': never;
|
||||
};
|
||||
|
||||
export interface CanvasNodeInjectionData {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type TelemetryNdvSource =
|
|||
| 'canvas_zoomed_view'
|
||||
| 'focus_panel'
|
||||
| 'logs_view'
|
||||
| 'command_bar'
|
||||
| 'other';
|
||||
|
||||
export type TelemetryContext = Partial<{
|
||||
|
|
|
|||
|
|
@ -1449,6 +1449,16 @@ function removeSourceControlEventBindings() {
|
|||
sourceControlEventBus.off('pull', onSourceControlPull);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command bar
|
||||
* */
|
||||
function addCommandBarEventBindings() {
|
||||
canvasEventBus.on('create:sticky', onCreateSticky);
|
||||
}
|
||||
function removeCommandBarEventBindings() {
|
||||
canvasEventBus.off('create:sticky', onCreateSticky);
|
||||
}
|
||||
|
||||
/**
|
||||
* Post message events
|
||||
*/
|
||||
|
|
@ -1914,10 +1924,11 @@ onMounted(() => {
|
|||
addBeforeUnloadEventBindings();
|
||||
addImportEventBindings();
|
||||
addExecutionOpenedEventBindings();
|
||||
addCommandBarEventBindings();
|
||||
registerCustomActions();
|
||||
});
|
||||
|
||||
onActivated(async () => {
|
||||
onActivated(() => {
|
||||
addUndoRedoEventBindings();
|
||||
showAddFirstStepIfEnabled();
|
||||
});
|
||||
|
|
@ -1934,6 +1945,7 @@ onBeforeUnmount(() => {
|
|||
removeBeforeUnloadEventBindings();
|
||||
removeImportEventBindings();
|
||||
removeExecutionOpenedEventBindings();
|
||||
removeCommandBarEventBindings();
|
||||
unregisterCustomActions();
|
||||
if (!isDemoRoute.value) {
|
||||
pushConnectionStore.pushDisconnect();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user