Skip to content

Commit c2b30c2

Browse files
feat(apollo-react): add nodeLabel for canvas display, distinct from palette label
Introduces an optional display.nodeLabel field on the node manifest that takes precedence over display.label when rendering on the canvas, while the palette and nodes list keep using the long-form label. Search now also indexes nodeLabel. Instance-level user renames continue to override manifest defaults (precedence: instance.label > manifest.nodeLabel > manifest.label). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4498be3 commit c2b30c2

9 files changed

Lines changed: 349 additions & 6 deletions

File tree

packages/apollo-react/src/canvas/components/AddNodePanel/AddNodePanel.stories.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
22
import type { Node } from '@uipath/apollo-react/canvas/xyflow/react';
33
import { Panel, Position, useReactFlow } from '@uipath/apollo-react/canvas/xyflow/react';
44
import { useEffect, useMemo, useState } from 'react';
5+
import { NodeRegistryProvider } from '../../core';
56
import { useAddNodeOnConnectEnd, useCanvasEvent } from '../../hooks';
7+
import type { CategoryManifest, NodeManifest } from '../../schema';
68
import {
9+
allCategoryManifests,
10+
allNodeManifests,
711
createNode,
812
NodePositions,
913
StoryInfoPanel,
@@ -415,6 +419,117 @@ export const NodePanelRegistryItems: Story = {
415419
),
416420
};
417421

422+
// ============================================================================
423+
// Search by nodeLabel
424+
// ============================================================================
425+
426+
/**
427+
* Story-local manifest registering a node whose only "outlook"-related token
428+
* lives on `display.nodeLabel`. Used to verify that CategoryTree.filterBySearch
429+
* indexes nodeLabel — typing "outlook" in the panel search must surface the
430+
* node even though `display.label` doesn't contain that token.
431+
*/
432+
const nodeLabelSearchManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = {
433+
categories: [
434+
...allCategoryManifests,
435+
{
436+
id: 'communications',
437+
name: 'Communications',
438+
sortOrder: 99,
439+
color: '#3b82f6',
440+
colorDark: '#60a5fa',
441+
icon: 'agent',
442+
tags: [],
443+
},
444+
],
445+
nodes: [
446+
...allNodeManifests,
447+
{
448+
nodeType: 'uipath.send-mail-via-outlook',
449+
version: '1.0.0',
450+
category: 'communications',
451+
tags: [],
452+
sortOrder: 1,
453+
description: 'Dispatch an email through the corporate mail provider.',
454+
display: {
455+
label: 'Send Email',
456+
nodeLabel: 'Outlook',
457+
icon: 'agent',
458+
shape: 'rectangle',
459+
},
460+
handleConfiguration: [
461+
{ position: 'left', handles: [{ id: 'input', type: 'target', handleType: 'input' }] },
462+
{ position: 'right', handles: [{ id: 'output', type: 'source', handleType: 'output' }] },
463+
],
464+
},
465+
],
466+
};
467+
468+
function NodeLabelSearchStory() {
469+
const initialNodes = useMemo(
470+
() => [
471+
createNode({
472+
id: 'trigger',
473+
type: 'uipath.manual-trigger',
474+
position: NodePositions.row2col1,
475+
display: { label: 'Manual trigger' },
476+
}),
477+
],
478+
[]
479+
);
480+
const { canvasProps } = useCanvasStory({ initialNodes });
481+
482+
const reactFlowInstance = useReactFlow();
483+
484+
useCanvasEvent('handle:action', (event: CanvasHandleActionEvent) => {
485+
if (!reactFlowInstance) return;
486+
487+
const { handleId, nodeId, position, handleType } = event;
488+
if (handleId && nodeId) {
489+
const sourceHandleType = handleType === 'input' ? 'target' : 'source';
490+
createAddNodePreview(
491+
nodeId,
492+
handleId,
493+
reactFlowInstance,
494+
position as Position,
495+
sourceHandleType
496+
);
497+
}
498+
});
499+
500+
return (
501+
<BaseCanvas {...canvasProps} mode="design" defaultViewport={{ x: 0, y: 0, zoom: 1 }}>
502+
<AddNodeManager />
503+
<Panel position="bottom-right">
504+
<CanvasPositionControls translations={DefaultCanvasTranslations} />
505+
</Panel>
506+
<StoryInfoPanel
507+
title="Search by 'nodeLabel'"
508+
description={
509+
'Click the + handle on the trigger node to open the Add node panel. ' +
510+
'The story-local manifest registers a "Send Email" node whose only ' +
511+
'"outlook" token lives on display.nodeLabel — its label, description, ' +
512+
'tags and nodeType deliberately exclude that word. Type "outlook" into ' +
513+
'the search and the node still surfaces because CategoryTree.filterBySearch ' +
514+
'now indexes nodeLabel.'
515+
}
516+
/>
517+
</BaseCanvas>
518+
);
519+
}
520+
521+
export const NodePanelSearchByNodeLabel: Story = {
522+
name: 'Search by nodeLabel',
523+
decorators: [
524+
(Story) => (
525+
<NodeRegistryProvider manifest={nodeLabelSearchManifest}>
526+
<Story />
527+
</NodeRegistryProvider>
528+
),
529+
],
530+
render: () => <NodeLabelSearchStory />,
531+
};
532+
418533
// ============================================================================
419534
// childrenLoading demos
420535
// ============================================================================

packages/apollo-react/src/canvas/components/BaseNode/BaseNode.stories.tsx

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,144 @@ export const StackedTreatment: Story = {
900900
render: () => <StackedTreatmentStory />,
901901
};
902902

903+
// ============================================================================
904+
// nodeLabel Resolution Story
905+
// ============================================================================
906+
907+
/**
908+
* Story-only manifest with two node types: one declaring both `label` (palette)
909+
* and `nodeLabel` (canvas), one declaring only `label`. Used to demo the
910+
* precedence in `resolveDisplay`: instance.label > manifest.nodeLabel > manifest.label.
911+
*/
912+
const nodeLabelManifest: { nodes: NodeManifest[]; categories: CategoryManifest[] } = {
913+
categories: [
914+
...allCategoryManifests,
915+
{
916+
id: 'communications',
917+
name: 'Communications',
918+
sortOrder: 99,
919+
color: '#3b82f6',
920+
colorDark: '#60a5fa',
921+
icon: 'agent',
922+
tags: [],
923+
},
924+
],
925+
nodes: [
926+
...allNodeManifests,
927+
{
928+
nodeType: 'uipath.send-outlook-email',
929+
version: '1.0.0',
930+
category: 'communications',
931+
tags: ['email', 'outlook'],
932+
sortOrder: 1,
933+
display: {
934+
label: 'Send Outlook 365 Email',
935+
nodeLabel: 'Send Email',
936+
icon: 'agent',
937+
shape: 'rectangle',
938+
},
939+
handleConfiguration: [
940+
{ position: 'left', handles: [{ id: 'input', type: 'target', handleType: 'input' }] },
941+
{ position: 'right', handles: [{ id: 'output', type: 'source', handleType: 'output' }] },
942+
],
943+
},
944+
{
945+
nodeType: 'uipath.long-decision',
946+
version: '1.0.0',
947+
category: 'communications',
948+
tags: ['decision'],
949+
sortOrder: 2,
950+
display: {
951+
label: 'Long Decision Without nodeLabel',
952+
icon: 'agent',
953+
shape: 'rectangle',
954+
},
955+
handleConfiguration: [
956+
{ position: 'left', handles: [{ id: 'input', type: 'target', handleType: 'input' }] },
957+
{ position: 'right', handles: [{ id: 'output', type: 'source', handleType: 'output' }] },
958+
],
959+
},
960+
],
961+
};
962+
963+
function NodeLabelStory() {
964+
const initialNodes = useMemo<Node<BaseNodeData>[]>(
965+
() => [
966+
createNode({
967+
id: 'with-nodelabel',
968+
type: 'uipath.send-outlook-email',
969+
position: { x: 96, y: 160 },
970+
data: {
971+
nodeType: 'uipath.send-outlook-email',
972+
version: '1.0.0',
973+
display: { shape: 'rectangle', subLabel: 'manifest.nodeLabel wins' },
974+
},
975+
}),
976+
createNode({
977+
id: 'without-nodelabel',
978+
type: 'uipath.long-decision',
979+
position: { x: 96, y: 320 },
980+
data: {
981+
nodeType: 'uipath.long-decision',
982+
version: '1.0.0',
983+
display: {
984+
shape: 'rectangle',
985+
subLabel: 'falls back to manifest.label since nodeLabel is not defined',
986+
},
987+
},
988+
}),
989+
createNode({
990+
id: 'instance-rename',
991+
type: 'uipath.send-outlook-email',
992+
position: { x: 96, y: 480 },
993+
data: {
994+
nodeType: 'uipath.send-outlook-email',
995+
version: '1.0.0',
996+
display: {
997+
shape: 'rectangle',
998+
label: 'Notify Ops Team',
999+
subLabel: 'instance.label overrides nodeLabel',
1000+
},
1001+
},
1002+
}),
1003+
],
1004+
[]
1005+
);
1006+
const { canvasProps } = useCanvasStory({ initialNodes });
1007+
1008+
return (
1009+
<BaseCanvas {...canvasProps} mode="design">
1010+
<Panel position="bottom-right">
1011+
<CanvasPositionControls translations={DefaultCanvasTranslations} />
1012+
</Panel>
1013+
<StoryInfoPanel
1014+
title="nodeLabel resolution"
1015+
description={
1016+
'Three instances of the same canvas, exercising the precedence ' +
1017+
'instance.label > manifest.nodeLabel > manifest.label resolved by ' +
1018+
'resolveDisplay(). Top: manifest declares both label ("Send Outlook 365 Email") ' +
1019+
'and nodeLabel ("Send Email") — canvas renders the short nodeLabel. ' +
1020+
'Middle: manifest declares only label — canvas falls back to it. ' +
1021+
'Bottom: instance overrides label ("Notify Ops Team") — user rename wins ' +
1022+
'over manifest.nodeLabel.'
1023+
}
1024+
/>
1025+
</BaseCanvas>
1026+
);
1027+
}
1028+
1029+
export const NodeLabel: Story = {
1030+
name: 'nodeLabel resolution',
1031+
decorators: [
1032+
(Story) => (
1033+
<NodeRegistryProvider manifest={nodeLabelManifest}>
1034+
<Story />
1035+
</NodeRegistryProvider>
1036+
),
1037+
],
1038+
render: () => <NodeLabelStory />,
1039+
};
1040+
9031041
export const ValidationStates: Story = {
9041042
name: 'Validation States',
9051043
decorators: [

packages/apollo-react/src/canvas/core/CategoryTree.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,63 @@ describe('CategoryTree', () => {
510510

511511
expect(filesCategory?.nodes).toHaveLength(2);
512512
});
513+
514+
it('should filter nodes by nodeLabel when only nodeLabel matches', () => {
515+
const categories = createMockCategories();
516+
const nodes: NodeManifest[] = [
517+
...createMockNodes(),
518+
{
519+
nodeType: 'send-outlook-email',
520+
category: 'automation.email',
521+
version: '1.0.0',
522+
display: {
523+
label: 'Send Outlook 365 Message',
524+
nodeLabel: 'Outlook',
525+
icon: 'mail',
526+
},
527+
description: 'Dispatch a message via Outlook 365',
528+
sortOrder: 5,
529+
handleConfiguration: [],
530+
tags: [],
531+
},
532+
];
533+
const tree = new CategoryTree(categories, nodes);
534+
535+
const filtered = tree.filterBySearch('outlook');
536+
const emailCategory = filtered.findCategory('automation.email');
537+
538+
expect(emailCategory?.nodes).toHaveLength(1);
539+
expect(emailCategory?.nodes[0]?.nodeType).toBe('send-outlook-email');
540+
});
541+
542+
it('should match multi-word search across label and nodeLabel', () => {
543+
const categories = createMockCategories();
544+
const nodes: NodeManifest[] = [
545+
...createMockNodes(),
546+
{
547+
nodeType: 'send-outlook-email',
548+
category: 'automation.email',
549+
version: '1.0.0',
550+
display: {
551+
label: 'Send Outlook 365 Message',
552+
nodeLabel: 'Quick Send',
553+
icon: 'mail',
554+
},
555+
description: 'Dispatch a message via Outlook 365',
556+
sortOrder: 5,
557+
handleConfiguration: [],
558+
tags: [],
559+
},
560+
];
561+
const tree = new CategoryTree(categories, nodes);
562+
563+
// "outlook" matches label, "quick" matches nodeLabel
564+
const filtered = tree.filterBySearch('outlook quick');
565+
const emailCategory = filtered.findCategory('automation.email');
566+
567+
expect(emailCategory?.nodes).toHaveLength(1);
568+
expect(emailCategory?.nodes[0]?.nodeType).toBe('send-outlook-email');
569+
});
513570
});
514571

515572
describe('filterByConnections', () => {

packages/apollo-react/src/canvas/core/CategoryTree.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,8 @@ export class CategoryTree {
308308
* Filter the tree by search term using combination search.
309309
*
310310
* The search term is split into words. A node matches if every word matches
311-
* at least one searchable attribute: node label, type, description, tags,
312-
* or ancestor category names.
311+
* at least one searchable attribute: node label, node display label, type,
312+
* description, tags, or ancestor category names.
313313
*
314314
* This allows queries like "Outlook Email" to match a node named "Archive Email"
315315
* inside a "Microsoft Outlook 365" category.
@@ -333,6 +333,7 @@ export class CategoryTree {
333333
const matchesAllWords = (node: NodeManifest, ancestorCategoryNames: string[]): boolean => {
334334
const searchableTexts = [
335335
node.display.label.toLowerCase(),
336+
...(node.display.nodeLabel ? [node.display.nodeLabel.toLowerCase()] : []),
336337
node.nodeType.toLowerCase(),
337338
...(node.description ? [node.description.toLowerCase()] : []),
338339
...(node.tags

packages/apollo-react/src/canvas/core/NodeTypeRegistry.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,9 +364,12 @@ describe('NodeTypeRegistry', () => {
364364
expect(data.display!.icon).toBe('trigger');
365365
});
366366

367-
it('should use manifest label when no label provided', () => {
367+
it('should not seed display.label from manifest when no label provided', () => {
368+
// resolveDisplay() derives the rendered label from manifest.nodeLabel ?? manifest.label,
369+
// so leaving instance.label unset lets manifest changes propagate at render time.
368370
const data = registry.createDefaultData('trigger');
369-
expect(data.display!.label).toBe('Trigger');
371+
expect(data.display!.label).toBeUndefined();
372+
expect(data.display!.icon).toBe('trigger');
370373
});
371374

372375
it('should handle non-existent node types gracefully', () => {

packages/apollo-react/src/canvas/core/NodeTypeRegistry.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,9 @@ export class NodeTypeRegistry {
204204
};
205205
}
206206

207-
// Build default display from manifest
207+
// Omit `label` unless caller supplied one — resolveDisplay() derives it from manifest.nodeLabel ?? manifest.label.
208208
const display: InstanceDisplayConfig = {
209-
label: label || manifest.display.label,
209+
...(label ? { label } : {}),
210210
icon: manifest.display.icon,
211211
shape: manifest.display.shape,
212212
color: manifest.display.color,

packages/apollo-react/src/canvas/schema/node-definition/node-manifest.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export const nodeDisplayManifestSchema = z.object({
2929
/** Human-readable display name */
3030
label: z.string().min(1),
3131

32+
/** Human readable node display name visible in the canvas */
33+
nodeLabel: z.string().optional(),
34+
3235
/** Description of what the node does */
3336
description: z.string().optional(),
3437

0 commit comments

Comments
 (0)