Skip to content

Commit 4fd542e

Browse files
committed
feat: Add Sticky Notes to Canvas
1 parent 0e1efcd commit 4fd542e

15 files changed

Lines changed: 316 additions & 16 deletions

File tree

src/components/Editor/Context/PipelineDetails.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { useFlagValue } from "@/components/shared/Settings/useFlags";
1313
import { BlockStack } from "@/components/ui/layout";
1414
import useToastNotification from "@/hooks/useToastNotification";
1515
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
16+
import { FLEX_NODES_ANNOTATION } from "@/utils/annotations";
1617
import { getComponentFileFromList } from "@/utils/componentStore";
1718
import { USER_PIPELINES_LIST_NAME } from "@/utils/constants";
1819

@@ -79,12 +80,12 @@ const PipelineDetails = () => {
7980
},
8081
];
8182

82-
const annotations = Object.entries(
83-
componentSpec.metadata?.annotations || {},
84-
).map(([key, value]) => ({
85-
label: key,
86-
value: String(value),
87-
}));
83+
const annotations = Object.entries(componentSpec.metadata?.annotations || {})
84+
.filter(([key]) => key !== FLEX_NODES_ANNOTATION)
85+
.map(([key, value]) => ({
86+
label: key,
87+
value: String(value),
88+
}));
8889

8990
const actions = [
9091
<RenamePipeline key="rename-pipeline-action" />,

src/components/PipelineRun/RunDetails.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Text } from "@/components/ui/typography";
1111
import { useBackend } from "@/providers/BackendProvider";
1212
import { useComponentSpec } from "@/providers/ComponentSpecProvider";
1313
import { useExecutionData } from "@/providers/ExecutionDataProvider";
14+
import { FLEX_NODES_ANNOTATION } from "@/utils/annotations";
1415
import {
1516
flattenExecutionStatusStats,
1617
getExecutionStatusLabel,
@@ -101,10 +102,12 @@ export const RunDetails = () => {
101102
{Object.keys(annotations).length > 0 && (
102103
<KeyValueList
103104
title="Annotations"
104-
items={Object.entries(annotations).map(([key, value]) => ({
105-
label: key,
106-
value: String(value),
107-
}))}
105+
items={Object.entries(annotations)
106+
.filter(([key]) => key !== FLEX_NODES_ANNOTATION)
107+
.map(([key, value]) => ({
108+
label: key,
109+
value: String(value),
110+
}))}
108111
/>
109112
)}
110113

src/components/shared/ReactFlow/FlowCanvas/ConfirmationDialogs/DeleteConfirmation.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { type Node } from "@xyflow/react";
2+
13
import { createStringList } from "@/utils/string";
24

3-
import type { NodesAndEdges } from "../types";
5+
import { isFlexNode, type NodesAndEdges } from "../types";
46
import { thisCannotBeUndone } from "./shared";
57

68
export function getDeleteConfirmationDetails(deletedElements: NodesAndEdges) {
@@ -11,6 +13,18 @@ export function getDeleteConfirmationDetails(deletedElements: NodesAndEdges) {
1113
const isDeletingMultipleNodes = deletedNodes.length > 1;
1214

1315
if (!isDeletingMultipleNodes) {
16+
const node = deletedNodes[0];
17+
18+
if (isFlexNode(node)) {
19+
const singleDeleteTitle = `Delete ${node.data.type}?`;
20+
const singleDeleteDesc = `Title: '${node.data.properties.title}'`;
21+
22+
return {
23+
title: singleDeleteTitle,
24+
description: singleDeleteDesc,
25+
};
26+
}
27+
1428
const singleDeleteTitle =
1529
"Delete Node" +
1630
(deletedNodes.length > 0 ? ` '${deletedNodes[0].id}'` : "") +
@@ -31,14 +45,32 @@ export function getDeleteConfirmationDetails(deletedElements: NodesAndEdges) {
3145
};
3246
}
3347

48+
const sortedDeletedNodes = sortFlexNodesLast(deletedNodes);
49+
3450
const multiDeleteTitle = `Delete Nodes?`;
3551

3652
const deletedNodeList = createStringList(
37-
deletedNodes.map((node) => node.id),
53+
getNodeIdsForDisplay(sortedDeletedNodes),
3854
2,
3955
"node",
4056
);
4157

58+
if (sortedDeletedNodes.every(isFlexNode)) {
59+
const multiDeleteDesc = (
60+
<div className="text-sm">
61+
<p>{`This will delete ${deletedNodeList}.`}</p>
62+
<br />
63+
{thisCannotBeUndone}
64+
</div>
65+
);
66+
67+
return {
68+
title: multiDeleteTitle,
69+
content: multiDeleteDesc,
70+
description: "",
71+
};
72+
}
73+
4274
const multiDeleteDesc = (
4375
<div className="text-sm">
4476
<p>{`Deleting ${deletedNodeList} will also remove all connections to and from these nodes.`}</p>
@@ -86,3 +118,24 @@ export function getDeleteConfirmationDetails(deletedElements: NodesAndEdges) {
86118
// Fallback to default
87119
return {};
88120
}
121+
122+
function getNodeIdsForDisplay(nodes: Node[]) {
123+
return nodes.map((node) => {
124+
if (isFlexNode(node)) {
125+
return `'${node.data.properties.title}' (${node.data.type})`;
126+
}
127+
return node.id;
128+
});
129+
}
130+
131+
function sortFlexNodesLast(nodes: Node[]) {
132+
return [...nodes].sort((a, b) => {
133+
const aIsFlex = isFlexNode(a);
134+
const bIsFlex = isFlexNode(b);
135+
136+
if (aIsFlex && !bIsFlex) return 1;
137+
if (!aIsFlex && bIsFlex) return -1;
138+
139+
return 0;
140+
});
141+
}

src/components/shared/ReactFlow/FlowCanvas/FlexNode/types.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,26 @@ export interface FlexNodeData extends Record<string, unknown> {
1010
readOnly?: boolean;
1111
}
1212

13+
export type FlexNodeSpec = {
14+
type: FlexNodeType;
15+
properties: StickyNoteProperties;
16+
size: string;
17+
position: string;
18+
};
19+
1320
type StickyNoteProperties = {
1421
title: string;
1522
content: string;
1623
color: string;
1724
border: string;
1825
zIndex: number;
1926
};
27+
28+
export function parseFlexNodeSpec(spec: FlexNodeSpec): FlexNodeData {
29+
return {
30+
type: spec.type,
31+
properties: spec.properties,
32+
size: JSON.parse(spec.size),
33+
position: JSON.parse(spec.position),
34+
};
35+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { type Node } from "@xyflow/react";
2+
3+
import { type FlexNodeSpec, parseFlexNodeSpec } from "./types";
4+
5+
export const DEFAULT_STICKY_NOTE = {
6+
title: "Sticky Note",
7+
content: "",
8+
color: "#FFF9C4",
9+
border: "#F0F0F0",
10+
zIndex: 1,
11+
};
12+
13+
export const DEFAULT_FLEX_NODE_SIZE = { width: 150, height: 100 };
14+
15+
export const createFlexNode = (
16+
flexNode: [string, FlexNodeSpec],
17+
readOnly: boolean,
18+
) => {
19+
const [nodeId, flexSpec] = flexNode;
20+
21+
const flexNodeData = parseFlexNodeSpec(flexSpec);
22+
23+
const { position, size } = flexNodeData;
24+
25+
return {
26+
id: nodeId,
27+
data: { ...flexNodeData, readOnly },
28+
...size,
29+
position,
30+
type: "flex",
31+
connectable: false,
32+
} as Node;
33+
};

src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
type NodesAndEdges,
7171
nodeTypes,
7272
} from "./types";
73+
import addFlexNode from "./utils/addFlexNode";
7374
import addTask from "./utils/addTask";
7475
import { createConnectedIONode } from "./utils/createConnectedIONode";
7576
import { duplicateNodes } from "./utils/duplicateNodes";
@@ -82,7 +83,7 @@ import { removeNode } from "./utils/removeNode";
8283
import { replaceTaskNode } from "./utils/replaceTaskNode";
8384
import { updateNodePositions } from "./utils/updateNodePosition";
8485

85-
const SELECTABLE_NODES = new Set(["task", "input", "output"]);
86+
const SELECTABLE_NODES = new Set(["task", "input", "output", "flex"]);
8687
const UPGRADEABLE_NODES = new Set(["task"]);
8788
const REPLACEABLE_NODES = new Set(["task"]);
8889
const FAST_PLACE_NODE_TYPES = new Set<Node["type"]>(["task"]);
@@ -542,7 +543,25 @@ const FlowCanvas = ({
542543
const { spec: droppedTask, nodeType } = getNodeFromEvent(event);
543544

544545
if (!nodeType) {
545-
console.error("Dropped task type not identified.");
546+
console.error("Dropped node type not identified.");
547+
return;
548+
}
549+
550+
if (nodeType === "flex" && reactFlowInstance) {
551+
const position = getPositionFromEvent(event, reactFlowInstance);
552+
553+
const { spec: updatedSubgraphSpec } = addFlexNode(
554+
position,
555+
currentSubgraphSpec,
556+
);
557+
558+
const newRootSpec = updateSubgraphSpec(
559+
componentSpec,
560+
currentSubgraphPath,
561+
updatedSubgraphSpec,
562+
);
563+
564+
setComponentSpec(newRootSpec);
546565
return;
547566
}
548567

src/components/shared/ReactFlow/FlowCanvas/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { TaskType } from "@/types/taskNode";
55

66
import SmoothEdge from "./Edges/SmoothEdge";
77
import FlexNode from "./FlexNode/FlexNode";
8+
import type { FlexNodeData } from "./FlexNode/types";
89
import GhostNode from "./GhostNode/GhostNode";
910
import IONode from "./IONode/IONode";
1011
import TaskNode from "./TaskNode/TaskNode";
@@ -31,3 +32,7 @@ export const edgeTypes: Record<string, ComponentType<any>> = {
3132
export function isTaskNodeType(type: string): type is TaskType {
3233
return type === "task" || type === "input" || type === "output";
3334
}
35+
36+
export function isFlexNode(node: Node): node is Node<FlexNodeData> {
37+
return node.type === "flex";
38+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { XYPosition } from "@xyflow/react";
2+
import { nanoid } from "nanoid";
3+
4+
import { FLEX_NODES_ANNOTATION } from "@/utils/annotations";
5+
import { type ComponentSpec } from "@/utils/componentSpec";
6+
import { deepClone } from "@/utils/deepClone";
7+
8+
import type { FlexNodeSpec } from "../FlexNode/types";
9+
import { DEFAULT_FLEX_NODE_SIZE, DEFAULT_STICKY_NOTE } from "../FlexNode/utils";
10+
11+
interface AddFlexNodeResult {
12+
spec: ComponentSpec;
13+
nodeId: string;
14+
}
15+
16+
/**
17+
* Creates a flex node (sticky note) and adds it to the component annotations.
18+
*
19+
* Nodes are created with default properties which can be edited later.
20+
*
21+
* @param position - Canvas position {x, y} where the node should be visually placed
22+
* @param componentSpec - The component specification to modify (will be cloned, not mutated)
23+
* @returns Object containing the updated spec and nodeId
24+
*
25+
* @example
26+
* // Create a flex node
27+
* const result = addFlexNode({ x: 100, y: 200 }, componentSpec);
28+
*
29+
*/
30+
const addFlexNode = (
31+
position: XYPosition,
32+
componentSpec: ComponentSpec,
33+
): AddFlexNodeResult => {
34+
const newComponentSpec = deepClone(componentSpec);
35+
36+
if (!newComponentSpec.metadata?.annotations) {
37+
newComponentSpec.metadata = {};
38+
}
39+
40+
if (!newComponentSpec.metadata.annotations) {
41+
newComponentSpec.metadata.annotations = {};
42+
}
43+
44+
const nodeId = nanoid();
45+
46+
const flexNodeSpec: FlexNodeSpec = {
47+
type: "sticky-note",
48+
properties: DEFAULT_STICKY_NOTE,
49+
size: JSON.stringify(DEFAULT_FLEX_NODE_SIZE),
50+
position: JSON.stringify(position),
51+
};
52+
53+
newComponentSpec.metadata.annotations = {
54+
...newComponentSpec.metadata.annotations,
55+
[FLEX_NODES_ANNOTATION]: {
56+
...(newComponentSpec.metadata.annotations[FLEX_NODES_ANNOTATION] || {}),
57+
[nodeId]: flexNodeSpec,
58+
},
59+
};
60+
61+
return { spec: newComponentSpec, nodeId };
62+
};
63+
64+
export default addFlexNode;

src/components/shared/ReactFlow/FlowCanvas/utils/removeNode.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type Node } from "@xyflow/react";
22

3+
import { FLEX_NODES_ANNOTATION } from "@/utils/annotations";
34
import {
45
type ComponentSpec,
56
isGraphImplementation,
@@ -29,6 +30,10 @@ export const removeNode = (node: Node, componentSpec: ComponentSpec) => {
2930
return removeGraphOutput(outputName, componentSpec);
3031
}
3132

33+
if (node.type === "flex") {
34+
return removeFlexNode(node.id, componentSpec);
35+
}
36+
3237
return componentSpec;
3338
};
3439

@@ -146,3 +151,22 @@ export const removeTask = (
146151

147152
return componentSpec;
148153
};
154+
155+
const removeFlexNode = (
156+
nodeIdToRemove: string,
157+
componentSpec: ComponentSpec,
158+
) => {
159+
const newAnnotations = { ...componentSpec.metadata?.annotations };
160+
if (newAnnotations && newAnnotations[FLEX_NODES_ANNOTATION]) {
161+
const newStickyNotes = { ...newAnnotations[FLEX_NODES_ANNOTATION] };
162+
delete newStickyNotes[nodeIdToRemove];
163+
newAnnotations[FLEX_NODES_ANNOTATION] = newStickyNotes;
164+
}
165+
return {
166+
...componentSpec,
167+
metadata: {
168+
...componentSpec.metadata,
169+
annotations: newAnnotations,
170+
},
171+
};
172+
};

0 commit comments

Comments
 (0)