From b2a4acdabbe46bad18e4033a20efcc8f45d5c981 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Wed, 1 Oct 2025 14:31:52 +0300 Subject: [PATCH] feat(editor): In progress version of the Command bar (no-changelog) (#20198) Co-authored-by: Jaakko Husso --- .../N8nCommandBar/CommandBar.stories.ts | 220 ++++++++++ .../components/N8nCommandBar/CommandBar.vue | 403 ++++++++++++++++++ .../N8nCommandBar/CommandBarItem.vue | 109 +++++ .../src/components/N8nCommandBar/index.ts | 3 + .../src/components/N8nCommandBar/types.ts | 14 + .../design-system/src/components/index.ts | 1 + .../frontend/@n8n/i18n/src/locales/en.json | 62 ++- packages/frontend/editor-ui/src/App.vue | 29 +- .../src/components/canvas/Canvas.vue | 1 + .../src/composables/commandBar/types.ts | 14 + .../composables/commandBar/useBaseCommands.ts | 23 + .../useCredentialNavigationCommands.ts | 172 ++++++++ .../useDataTableNavigationCommands.ts | 191 +++++++++ .../composables/commandBar/useNodeCommands.ts | 125 ++++++ .../commandBar/useTemplateCommands.ts | 43 ++ .../commandBar/useWorkflowCommands.ts | 220 ++++++++++ .../useWorkflowNavigationCommands.ts | 209 +++++++++ .../src/composables/useCanvasLayout.ts | 1 + .../src/composables/useCommandBar.ts | 132 ++++++ packages/frontend/editor-ui/src/constants.ts | 6 + .../frontend/editor-ui/src/types/canvas.ts | 2 + .../frontend/editor-ui/src/types/telemetry.ts | 1 + .../frontend/editor-ui/src/views/NodeView.vue | 14 +- 23 files changed, 1992 insertions(+), 3 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nCommandBar/index.ts create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/types.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/useBaseCommands.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/useCredentialNavigationCommands.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/useDataTableNavigationCommands.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/useNodeCommands.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/useTemplateCommands.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/useWorkflowCommands.ts create mode 100644 packages/frontend/editor-ui/src/composables/commandBar/useWorkflowNavigationCommands.ts create mode 100644 packages/frontend/editor-ui/src/composables/useCommandBar.ts diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts new file mode 100644 index 00000000000..4ec2b19c77f --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts @@ -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: '', + }, + 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: + '', + 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: ` +
+

+ Press ⌘ + K + or Ctrl + K + to open the command bar. Use arrow keys to navigate and Enter to select. +

+ +
+ `, + methods: { + onInputChange: action('input-change'), + onNavigateTo: action('navigate-to'), + onLoadMore: action('load-more'), + }, +}); + +export const SectionGrouping: StoryFn = () => ({ + components: { + N8nCommandBar, + }, + data: () => ({ + items: sampleItems, + }), + template: ` +
+

+ This example shows how items are grouped by sections: +
Recent items (no section) appear first +
• Then items are grouped by Actions, Navigation, Tools, and Settings sections +

+ +
+ `, + methods: { + onInputChange: action('input-change'), + onNavigateTo: action('navigate-to'), + onLoadMore: action('load-more'), + }, +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue new file mode 100644 index 00000000000..49b0dc16017 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue @@ -0,0 +1,403 @@ + + + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue new file mode 100644 index 00000000000..d2f972af378 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/index.ts new file mode 100644 index 00000000000..77e15135d42 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/index.ts @@ -0,0 +1,3 @@ +import N8nCommandBar from './CommandBar.vue'; + +export default N8nCommandBar; diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts new file mode 100644 index 00000000000..f2bebf8a81e --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts @@ -0,0 +1,14 @@ +import type { Component } from 'vue'; + +export interface CommandBarItem { + id: string; + title: string; + icon?: { html: string } | { component: Component; props?: Record }; + section?: string; + keywords?: string[]; + handler?: () => void | Promise; + href?: string; + children?: CommandBarItem[]; + placeholder?: string; + hasMoreChildren?: boolean; +} diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index c4fdce97c35..7b160559aff 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -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'; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index a8fa1c31b2a..f28296df265 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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" } diff --git a/packages/frontend/editor-ui/src/App.vue b/packages/frontend/editor-ui/src/App.vue index b766d9184ea..e434856e54a 100644 --- a/packages/frontend/editor-ui/src/App.vue +++ b/packages/frontend/editor-ui/src/App.vue @@ -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
+ + diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue index a01579629c6..890460137fe 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue @@ -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); }); diff --git a/packages/frontend/editor-ui/src/composables/commandBar/types.ts b/packages/frontend/editor-ui/src/composables/commandBar/types.ts new file mode 100644 index 00000000000..0d7f8389bfe --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/types.ts @@ -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; + handlers?: CommandBarEventHandlers; +} diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useBaseCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useBaseCommands.ts new file mode 100644 index 00000000000..cd5f1af3d84 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/useBaseCommands.ts @@ -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, + }; +} diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useCredentialNavigationCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useCredentialNavigationCommands.ts new file mode 100644 index 00000000000..9718b500069 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/useCredentialNavigationCommands.ts @@ -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; + activeNodeId: Ref; + currentProjectName: Ref; +}): 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([]); + + const personalProjectId = computed(() => { + return projectsStore.myProjects.find((p) => p.type === 'personal')?.id; + }); + + function orderResultByCurrentProjectFirst(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(() => { + return credentialResults.value.map((credential) => createCredentialCommand(credential, false)); + }); + + const rootCredentialItems = computed(() => { + if (lastQuery.value.length <= 2) { + return []; + } + return credentialResults.value.map((credential) => createCredentialCommand(credential, true)); + }); + + const credentialNavigationCommands = computed(() => { + 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, + }, + }; +} diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useDataTableNavigationCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useDataTableNavigationCommands.ts new file mode 100644 index 00000000000..1a1549ce3fb --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/useDataTableNavigationCommands.ts @@ -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; + activeNodeId: Ref; + currentProjectName: Ref; +}): CommandGroup { + const i18n = useI18n(); + const { lastQuery, activeNodeId, currentProjectName } = options; + const dataStoreStore = useDataStoreStore(); + const projectsStore = useProjectsStore(); + + const router = useRouter(); + const route = useRoute(); + + const dataTableResults = ref([]); + + 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(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(() => { + return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, false)); + }); + + const rootDataTableItems = computed(() => { + if (lastQuery.value.length <= 2) { + return []; + } + return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, true)); + }); + + const dataTableNavigationCommands = computed(() => { + 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, + }, + }; +} diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useNodeCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useNodeCommands.ts new file mode 100644 index 00000000000..90e67bfc0f1 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/useNodeCommands.ts @@ -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(() => { + 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: ``, + } + : undefined, + handler: async () => { + await addNodes([{ type: name }]); + }, + }; + }); + }); + + const openNodeCommands = computed(() => { + 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: ``, + } + : undefined, + handler: () => { + setNodeActive(id, 'command_bar'); + }, + placeholder: i18n.baseText('commandBar.nodes.searchPlaceholder'), + }; + }); + }); + + const nodeCommands = computed(() => { + 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, + }; +} diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useTemplateCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useTemplateCommands.ts new file mode 100644 index 00000000000..5d67e2af42e --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/useTemplateCommands.ts @@ -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, + }; +} diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowCommands.ts new file mode 100644 index 00000000000..9adaf6a09b3 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowCommands.ts @@ -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(() => { + 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(() => { + 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(() => { + 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, + }; +} diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowNavigationCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowNavigationCommands.ts new file mode 100644 index 00000000000..030fba55906 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowNavigationCommands.ts @@ -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; + activeNodeId: Ref; + currentProjectName: Ref; +}): 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([]); + + const personalProjectId = computed(() => { + return projectsStore.myProjects.find((p) => p.type === 'personal')?.id; + }); + + function orderResultByCurrentProjectFirst(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(() => { + return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, false)); + }); + + const rootWorkflowItems = computed(() => { + if (lastQuery.value.length <= 2) { + return []; + } + return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, true)); + }); + + const workflowNavigationCommands = computed(() => { + 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, + }, + }; +} diff --git a/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts b/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts index 02329ee5e4e..4c846ff5df6 100644 --- a/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts +++ b/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts @@ -17,6 +17,7 @@ export type CanvasLayoutSource = | 'keyboard-shortcut' | 'canvas-button' | 'context-menu' + | 'command-bar' | 'import-workflow-data'; export type CanvasLayoutTargetData = { nodes: Array>; diff --git a/packages/frontend/editor-ui/src/composables/useCommandBar.ts b/packages/frontend/editor-ui/src/composables/useCommandBar.ts new file mode 100644 index 00000000000..bbf3fc68dfe --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/useCommandBar.ts @@ -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(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(() => { + 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(() => { + 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, + }; +} diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 46af328c219..b48e9eb26b0 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/types/canvas.ts b/packages/frontend/editor-ui/src/types/canvas.ts index a45c72b0d1d..4100aed1794 100644 --- a/packages/frontend/editor-ui/src/types/canvas.ts +++ b/packages/frontend/editor-ui/src/types/canvas.ts @@ -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 { diff --git a/packages/frontend/editor-ui/src/types/telemetry.ts b/packages/frontend/editor-ui/src/types/telemetry.ts index 0533e026e04..b479d2778b9 100644 --- a/packages/frontend/editor-ui/src/types/telemetry.ts +++ b/packages/frontend/editor-ui/src/types/telemetry.ts @@ -8,6 +8,7 @@ export type TelemetryNdvSource = | 'canvas_zoomed_view' | 'focus_panel' | 'logs_view' + | 'command_bar' | 'other'; export type TelemetryContext = Partial<{ diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index 5c6570bc86e..5f65a17e134 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -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();