feat(editor): Tweak command bar labels (no-changelog) (#21228)

This commit is contained in:
Svetoslav Dekov
2025-11-03 10:47:20 +02:00
committed by GitHub
parent d35ba6b370
commit 342ec9c592
13 changed files with 207 additions and 125 deletions
@@ -42,6 +42,7 @@
"generic.any": "Any",
"generic.cancel": "Cancel",
"generic.open": "Open",
"generic.openResource": "Open {resource}",
"generic.add": "Add",
"generic.close": "Close",
"generic.clear": "Clear",
@@ -222,6 +222,14 @@ describe('useCredentialNavigationCommands', () => {
await vi.waitFor(() => {
const openCommand = commands.value.find((cmd) => cmd.id === 'open-credential');
expect(openCommand?.children).toHaveLength(1);
expect(openCommand?.children?.[0].section).toBe('commandBar.credentials.open');
expect(openCommand?.children?.[0].title).toEqual(
expect.objectContaining({
props: expect.objectContaining({
title: 'My API Key',
}),
}),
);
});
});
});
@@ -344,6 +352,14 @@ describe('useCredentialNavigationCommands', () => {
await vi.waitFor(() => {
const rootCredentials = commands.value.filter((cmd) => cmd.id === 'cred-1');
expect(rootCredentials).toHaveLength(1);
expect(rootCredentials[0].section).toBe('commandBar.sections.credentials');
expect(rootCredentials[0].title).toEqual(
expect.objectContaining({
props: expect.objectContaining({
title: 'generic.openResource',
}),
}),
);
});
});
});
@@ -79,21 +79,30 @@ export function useCredentialNavigationCommands(options: {
return credential.homeProject?.name ?? '';
};
const createCredentialCommand = (credential: ICredentialsResponse): CommandBarItem => {
const createCredentialCommand = (
credential: ICredentialsResponse,
isRoot: boolean,
): CommandBarItem => {
// Add credential name to keywords since we're using a custom component for the title
const keywords = [credential.name];
const title = isRoot
? i18n.baseText('generic.openResource', { interpolate: { resource: credential.name } })
: credential.name;
const section = isRoot
? i18n.baseText('commandBar.sections.credentials')
: i18n.baseText('commandBar.credentials.open');
return {
id: credential.id,
title: {
component: CommandBarItemTitle,
props: {
title: credential.name,
title,
suffix: getCredentialProjectSuffix(credential),
actionText: i18n.baseText('generic.open'),
},
},
section: i18n.baseText('commandBar.sections.credentials'),
section,
keywords,
icon: {
component: CredentialIcon,
@@ -108,14 +117,14 @@ export function useCredentialNavigationCommands(options: {
};
const openCredentialCommands = computed<CommandBarItem[]>(() => {
return credentialResults.value.map((credential) => createCredentialCommand(credential));
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));
return credentialResults.value.map((credential) => createCredentialCommand(credential, true));
});
const credentialNavigationCommands = computed<CommandBarItem[]>(() => {
@@ -299,6 +299,14 @@ describe('useDataTableNavigationCommands', () => {
await vi.waitFor(() => {
const rootDataTables = commands.value.filter((cmd) => cmd.id === 'dt-1');
expect(rootDataTables).toHaveLength(1);
expect(rootDataTables[0].section).toBe('commandBar.sections.dataTables');
expect(rootDataTables[0].title).toEqual(
expect.objectContaining({
props: expect.objectContaining({
title: 'generic.openResource',
}),
}),
);
});
});
});
@@ -87,21 +87,27 @@ export function useDataTableNavigationCommands(options: {
return dataTable.project?.name ?? '';
};
const createDataTableCommand = (dataTable: DataTable): CommandBarItem => {
const createDataTableCommand = (dataTable: DataTable, isRoot: boolean): CommandBarItem => {
// Add data table name to keywords since we're using a custom component for the title
const keywords = [dataTable.name];
const title = isRoot
? i18n.baseText('generic.openResource', { interpolate: { resource: dataTable.name } })
: dataTable.name;
const section = isRoot
? i18n.baseText('commandBar.sections.dataTables')
: i18n.baseText('commandBar.dataTables.open');
return {
id: dataTable.id,
title: {
component: CommandBarItemTitle,
props: {
title: dataTable.name,
title,
suffix: getDataTableProjectSuffix(dataTable),
actionText: i18n.baseText('generic.open'),
},
},
section: i18n.baseText('commandBar.sections.dataTables'),
section,
keywords,
handler: () => {
void router.push({
@@ -116,14 +122,14 @@ export function useDataTableNavigationCommands(options: {
};
const openDataTableCommands = computed<CommandBarItem[]>(() => {
return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable));
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));
return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, true));
});
const dataTableNavigationCommands = computed<CommandBarItem[]>(() => {
@@ -244,6 +244,7 @@ describe('useNodeCommands', () => {
const openCommand = commands.value.find((cmd) => cmd.id === 'open-node');
expect(openCommand?.children).toHaveLength(2);
expect(openCommand?.children?.[0].title).toEqual('Start');
});
});
@@ -297,15 +298,6 @@ describe('useNodeCommands', () => {
expect(commands.value.length).toBeLessThanOrEqual(3);
}
});
it('should show root add node items when query is longer than 2 characters', () => {
const { commands } = useNodeCommands({
lastQuery: ref('htt'),
activeNodeId: ref(null),
});
expect(commands.value).toBeDefined();
});
});
describe('root open node items', () => {
@@ -333,6 +325,7 @@ describe('useNodeCommands', () => {
const rootOpenNodes = commands.value.filter((cmd) => cmd.id === 'node-1');
expect(rootOpenNodes).toHaveLength(1);
expect(rootOpenNodes[0].title).toEqual('generic.openResource');
});
});
});
@@ -13,6 +13,7 @@ import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { getResourcePermissions } from '@n8n/permissions';
import NodeIcon from '@/app/components/NodeIcon.vue';
import CommandBarItemTitle from '@/features/shared/commandBar/components/CommandBarItemTitle.vue';
import type { INodeUi, SimplifiedNodeType } from '@/Interface';
const ITEM_ID = {
ADD_NODE: 'add-node',
@@ -45,81 +46,88 @@ export function useNodeCommands(options: {
(workflowPermissions.value[permission] === true && !isReadOnly.value && !isArchived.value) ||
workflowsStore.isNewWorkflow;
const mergedNodes = computed(() => {
const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
const nodeTypes = nodeTypesStore.visibleNodeTypes;
return generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials).mergedNodes;
});
const buildAddNodeCommand = (node: SimplifiedNodeType, isRoot: boolean): CommandBarItem => {
const { name, displayName } = node;
const title = isRoot ? `${i18n.baseText('generic.add')} ${displayName}` : displayName;
const section = isRoot
? i18n.baseText('commandBar.sections.nodes')
: i18n.baseText('commandBar.nodes.addNode');
return {
id: name,
title,
section,
keywords: [displayName],
icon: {
component: NodeIcon as Component,
props: {
nodeType: node,
size: 16,
},
},
handler: async () => {
const nodes = await addNodes([{ type: name }]);
if (nodes && nodes.length > 0) {
canvasEventBus.emit('nodes:select', { ids: [nodes[0].id] });
}
},
};
};
const addNodeCommands = computed<CommandBarItem[]>(() => {
if (!hasPermission('update')) {
return [];
}
const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
const nodeTypes = nodeTypesStore.visibleNodeTypes;
const { mergedNodes } = generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials);
return mergedNodes.map((node) => {
const { name, displayName } = node;
return {
id: name,
title: {
component: CommandBarItemTitle,
props: {
title: displayName,
actionText: i18n.baseText('generic.add'),
},
},
keywords: [displayName],
icon: {
component: NodeIcon as Component,
props: {
nodeType: node,
size: 16,
},
},
handler: async () => {
const nodes = await addNodes([{ type: name }]);
if (nodes && nodes.length > 0) {
canvasEventBus.emit('nodes:select', { ids: [nodes[0].id] });
}
},
};
});
return mergedNodes.value.map((node) => buildAddNodeCommand(node, false));
});
const rootAddNodeCommandItems = computed<CommandBarItem[]>(() => {
if (lastQuery.value.length <= 2) {
if (lastQuery.value.length <= 2 || !hasPermission('update')) {
return [];
}
return addNodeCommands.value;
return mergedNodes.value.map((node) => buildAddNodeCommand(node, true));
});
const openNodeCommands = computed<CommandBarItem[]>(() => {
return editableWorkflow.value.nodes.map((node) => {
const { id, name, type } = node;
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
const buildOpenNodeCommand = (node: INodeUi, isRoot: boolean): CommandBarItem => {
const { id, name, type } = node;
const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
const title = isRoot
? i18n.baseText('generic.openResource', { interpolate: { resource: name } })
: name;
const section = isRoot
? i18n.baseText('commandBar.sections.nodes')
: i18n.baseText('commandBar.nodes.openNode');
return {
id,
title: {
component: CommandBarItemTitle,
props: {
title: name,
actionText: i18n.baseText('generic.open'),
},
return {
id,
title,
section,
keywords: [name, type],
icon: {
component: NodeIcon,
props: {
nodeType,
size: 16,
},
section: i18n.baseText('commandBar.sections.nodes'),
keywords: [name, type],
icon: {
component: NodeIcon,
props: {
nodeType,
size: 16,
},
},
handler: () => {
setNodeActive(id, 'command_bar');
},
placeholder: i18n.baseText('commandBar.nodes.searchPlaceholder'),
};
});
},
handler: () => {
setNodeActive(id, 'command_bar');
},
placeholder: i18n.baseText('commandBar.nodes.searchPlaceholder'),
};
};
const openNodeCommands = computed<CommandBarItem[]>(() => {
return editableWorkflow.value.nodes.map((node) => buildOpenNodeCommand(node, false));
});
const rootOpenNodeCommandItems = computed<CommandBarItem[]>(() => {
@@ -127,7 +135,7 @@ export function useNodeCommands(options: {
return [];
}
return openNodeCommands.value;
return editableWorkflow.value.nodes.map((node) => buildOpenNodeCommand(node, true));
});
const nodeCommands = computed<CommandBarItem[]>(() => {
@@ -291,6 +291,7 @@ describe('useProjectNavigationCommands', () => {
const rootProjects = commands.value.filter((cmd) => cmd.id === 'project-1');
expect(rootProjects).toHaveLength(1);
expect(rootProjects[0].title).toEqual('generic.openResource');
});
});
@@ -341,12 +342,7 @@ describe('useProjectNavigationCommands', () => {
const openCommand = commands.value.find((cmd) => cmd.id === 'open-project');
const personalProject = openCommand?.children?.[0];
expect(personalProject).toBeDefined();
expect(personalProject?.title).toEqual({
component: expect.any(Object),
props: expect.objectContaining({
title: 'projects.menu.personal',
}),
});
expect(personalProject?.title).toEqual('projects.menu.personal');
});
it('should show project name for team project', () => {
@@ -365,12 +361,7 @@ describe('useProjectNavigationCommands', () => {
const openCommand = commands.value.find((cmd) => cmd.id === 'open-project');
const teamProject = openCommand?.children?.[0];
expect(teamProject).toBeDefined();
expect(teamProject?.title).toEqual({
component: expect.any(Object),
props: expect.objectContaining({
title: 'Marketing Team',
}),
});
expect(teamProject?.title).toEqual('Marketing Team');
});
it('should show unnamed text for team project without name', () => {
@@ -389,12 +380,7 @@ describe('useProjectNavigationCommands', () => {
const openCommand = commands.value.find((cmd) => cmd.id === 'open-project');
const unnamedProject = openCommand?.children?.[0];
expect(unnamedProject).toBeDefined();
expect(unnamedProject?.title).toEqual({
component: expect.any(Object),
props: expect.objectContaining({
title: 'commandBar.projects.unnamed',
}),
});
expect(unnamedProject?.title).toEqual('commandBar.projects.unnamed');
});
});
@@ -7,7 +7,6 @@ import type { ProjectListItem } from '@/features/collaboration/projects/projects
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { CommandBarItem } from '../types';
import { useGlobalEntityCreation } from '@/app/composables/useGlobalEntityCreation';
import CommandBarItemTitle from '@/features/shared/commandBar/components/CommandBarItemTitle.vue';
const ITEM_ID = {
CREATE_PROJECT: 'create-project',
@@ -40,24 +39,26 @@ export function useProjectNavigationCommands(options: {
);
});
const createProjectCommand = (project: ProjectListItem): CommandBarItem => {
const title =
const createProjectCommand = (project: ProjectListItem, isRoot: boolean): CommandBarItem => {
let title =
project.type === 'personal'
? i18n.baseText('projects.menu.personal')
: project.name
? project.name
: i18n.baseText('commandBar.projects.unnamed');
if (isRoot) {
title = i18n.baseText('generic.openResource', { interpolate: { resource: title } });
}
const section = isRoot
? i18n.baseText('commandBar.sections.projects')
: i18n.baseText('commandBar.projects.open');
return {
id: project.id,
title: {
component: CommandBarItemTitle,
props: {
title,
actionText: i18n.baseText('generic.open'),
},
},
section: i18n.baseText('commandBar.sections.projects'),
title,
section,
keywords: [title],
handler: () => {
void router.push({
@@ -71,13 +72,13 @@ export function useProjectNavigationCommands(options: {
const openProjectCommands = computed<CommandBarItem[]>(() => {
const isInProjectParent = activeNodeId.value === ITEM_ID.OPEN_PROJECT;
if (!isInProjectParent) return [];
return filteredProjects.value.map((project) => createProjectCommand(project));
return filteredProjects.value.map((project) => createProjectCommand(project, false));
});
const rootProjectItems = computed<CommandBarItem[]>(() => {
const isRootWithQuery = activeNodeId.value === null && lastQuery.value.trim().length > 2;
if (!isRootWithQuery) return [];
return filteredProjects.value.map((project) => createProjectCommand(project));
return filteredProjects.value.map((project) => createProjectCommand(project, true));
});
const projectNavigationCommands = computed<CommandBarItem[]>(() => {
@@ -308,7 +308,7 @@ describe('useRecentResources', () => {
expect(nodeItems).toHaveLength(2);
// node-2 is most recent since it was tracked last
expect(nodeItems[0].id).toBe('recent-node-workflow-1-node-2');
expect(nodeItems[0].title).toBe('Test Node 2');
expect(nodeItems[0].title).toBe('generic.openResource');
expect(nodeItems[1].id).toBe('recent-node-workflow-1-node-1');
expect(nodeItems[0].section).toBe('commandBar.sections.recent');
});
@@ -374,9 +374,9 @@ describe('useRecentResources', () => {
expect(workflowItems).toHaveLength(2);
expect(workflowItems[0].id).toBe('recent-workflow-workflow-2');
expect(workflowItems[0].title).toBe('Workflow 2');
expect(workflowItems[0].title).toBe('generic.openResource');
expect(workflowItems[1].id).toBe('recent-workflow-workflow-1');
expect(workflowItems[1].title).toBe('Workflow 1');
expect(workflowItems[1].title).toBe('generic.openResource');
});
it('should limit workflow items to MAX_RECENT_WORKFLOWS_TO_DISPLAY (3)', () => {
@@ -437,7 +437,7 @@ describe('useRecentResources', () => {
const items = commands.value;
const workflowItem = items.find((item) => item.id === 'recent-workflow-workflow-unnamed');
expect(workflowItem?.title).toBe('commandBar.workflows.unnamed');
expect(workflowItem?.title).toBe('generic.openResource');
});
it('should navigate using window.location.href when workflow item handler is executed', () => {
@@ -109,7 +109,9 @@ export function useRecentResources() {
items.push({
id: `recent-node-${currentWorkflowId}-${recentNode.nodeId}`,
title: node.name,
title: i18n.baseText('generic.openResource', {
interpolate: { resource: node.name },
}),
section: i18n.baseText('commandBar.sections.recent'),
icon: {
component: NodeIcon as Component,
@@ -145,7 +147,11 @@ export function useRecentResources() {
items.push({
id: `recent-workflow-${recentWorkflow.id}`,
title: workflow.name || i18n.baseText('commandBar.workflows.unnamed'),
title: i18n.baseText('generic.openResource', {
interpolate: {
resource: workflow.name || i18n.baseText('commandBar.workflows.unnamed'),
},
}),
section: i18n.baseText('commandBar.sections.recent'),
icon: {
component: N8nIcon,
@@ -304,4 +304,43 @@ describe('useWorkflowNavigationCommands', () => {
// Icon present when matched by node type
expect(item.icon).toBeDefined();
});
it('root workflow items have correct title and section', async () => {
const api = useWorkflowNavigationCommands({
lastQuery: ref('Alpha'),
activeNodeId: ref(null),
currentProjectName: ref('My Project'),
});
(api.handlers?.onCommandBarChange as (q: string) => void)('Alpha');
await waitFor(() => {
expect(api.commands.value.length).toBeGreaterThan(2);
});
const alphaWf = api.commands.value.find((c) => c.id === 'w1');
expect((alphaWf?.title as unknown as { props?: { title?: string } }).props?.title).toBe(
'generic.openResource',
);
expect(alphaWf?.section).toBe('commandBar.sections.workflows');
});
it('open workflow children have correct title and section', async () => {
const api = useWorkflowNavigationCommands({
lastQuery: ref(''),
activeNodeId: ref(null),
currentProjectName: ref('My Project'),
});
api.handlers?.onCommandBarNavigateTo?.('open-workflow');
await waitFor(() => {
const open = api.commands.value.find((c) => c.id === 'open-workflow');
expect(open?.children?.length).toBeGreaterThan(0);
});
const children = api.commands.value.find((c) => c.id === 'open-workflow')?.children;
if (!children) {
throw new Error('Open workflow command not found');
}
expect(children).toHaveLength(2);
expect((children[0].title as unknown as { props?: { title?: string } }).props?.title).toBe(
'Alpha',
);
expect(children[0].section).toBe('commandBar.workflows.open');
});
});
@@ -219,7 +219,7 @@ export function useWorkflowNavigationCommands(options: {
return parts.join(' / ');
};
const createWorkflowCommand = (workflow: IWorkflowDb): CommandBarItem => {
const createWorkflowCommand = (workflow: IWorkflowDb, isRoot: boolean): CommandBarItem => {
let keywords = workflowKeywords.value.get(workflow.id) ?? [];
const matchedNodeType = workflowMatchedNodeTypes.value.get(workflow.id);
@@ -251,18 +251,27 @@ export function useWorkflowNavigationCommands(options: {
const suffix = getWorkflowProjectSuffix(workflow);
const name = workflow.name || i18n.baseText('commandBar.workflows.unnamed');
const title = isRoot
? i18n.baseText('generic.openResource', {
interpolate: { resource: name },
})
: name;
const section = isRoot
? i18n.baseText('commandBar.sections.workflows')
: i18n.baseText('commandBar.workflows.open');
return {
id: workflow.id,
title: {
component: CommandBarItemTitle,
props: {
title: workflow.name || i18n.baseText('commandBar.workflows.unnamed'),
title,
suffix,
...(suffix ? { suffixIcon: getProjectIcon(workflow) } : {}),
actionText: i18n.baseText('generic.open'),
},
},
section: i18n.baseText('commandBar.sections.workflows'),
section,
...(keywords.length > 0 ? { keywords } : {}),
...(icon ? { icon } : {}),
handler: () => {
@@ -276,14 +285,14 @@ export function useWorkflowNavigationCommands(options: {
};
const openWorkflowCommands = computed<CommandBarItem[]>(() => {
return workflowResults.value.map((workflow) => createWorkflowCommand(workflow));
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));
return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, true));
});
const workflowNavigationCommands = computed<CommandBarItem[]>(() => {