@startsimpli/ui 0.4.26 → 0.4.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.26",
3
+ "version": "0.4.28",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -32,7 +32,7 @@
32
32
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
33
33
  "react": "^18.0.0 || ^19.0.0",
34
34
  "react-dom": "^18.0.0 || ^19.0.0",
35
- "@startsimpli/auth": "0.4.22"
35
+ "@startsimpli/auth": "0.4.24"
36
36
  },
37
37
  "peerDependenciesMeta": {
38
38
  "@startsimpli/auth": {
@@ -345,13 +345,15 @@ function AttachmentChip({
345
345
  {name}
346
346
  </span>
347
347
  <span className="text-[10px] text-muted-foreground">
348
- {status === 'uploading'
349
- ? `Uploading… ${pct}%`
350
- : status === 'error'
351
- ? (error || 'Upload failed')
352
- : sizeBytes != null
353
- ? formatBytes(sizeBytes)
354
- : null}
348
+ {status === 'queued'
349
+ ? 'Waiting for deck…'
350
+ : status === 'uploading'
351
+ ? `Uploading… ${pct}%`
352
+ : status === 'error'
353
+ ? (error || 'Upload failed')
354
+ : sizeBytes != null
355
+ ? formatBytes(sizeBytes)
356
+ : null}
355
357
  </span>
356
358
  </span>
357
359
  {status === 'error' && onRetry && (
@@ -40,7 +40,12 @@ export interface ChatMessageData {
40
40
  export interface PendingAttachment {
41
41
  id: string
42
42
  name: string
43
- status: 'uploading' | 'ready' | 'error'
43
+ /**
44
+ * `queued` = parked client-side, waiting for a document to land on (a brand-new
45
+ * deck has no document until the first turn creates it). It is NOT actively
46
+ * uploading — surfacing it as `uploading` at 0% reads as a stuck upload.
47
+ */
48
+ status: 'queued' | 'uploading' | 'ready' | 'error'
44
49
  /** Upload progress 0..1 while `status === 'uploading'`. */
45
50
  progress?: number
46
51
  /** Object/asset url for an image preview thumbnail. */
@@ -122,4 +122,50 @@ describe('useComposerUploads', () => {
122
122
  ])
123
123
  )
124
124
  })
125
+
126
+ // --- Regression: prod "dropped images never leave 0" (startsim-0fp3) ---
127
+ // When there is no document path (demo engine / brand-new deck whose document
128
+ // never arrives), a queued file used to surface as 'uploading' at progress 0
129
+ // indefinitely — no honest state, no way to know it's parked, no bound.
130
+ describe('no-document strand (startsim-0fp3)', () => {
131
+ it('surfaces a queued file with an honest non-uploading status, not 0%-uploading-forever', () => {
132
+ const uploader = fakeUploader()
133
+ const { result } = renderHook(() => useComposerUploads(uploader, undefined))
134
+ act(() => result.current.attachFiles([file('parked.png')]))
135
+ expect(result.current.attachments).toHaveLength(1)
136
+ // The user must be able to tell this is parked, not actively uploading.
137
+ expect(uploader.uploadAttachment).not.toHaveBeenCalled()
138
+ expect(result.current.attachments[0].status).toBe('queued')
139
+ })
140
+
141
+ it('removes a queued (never-uploaded) chip and revokes its object url', () => {
142
+ const revoke = jest.spyOn(URL, 'revokeObjectURL')
143
+ const uploader = fakeUploader()
144
+ const { result } = renderHook(() => useComposerUploads(uploader, undefined))
145
+ act(() => result.current.attachFiles([file('parked.png')]))
146
+ const chipId = result.current.attachments[0].id
147
+
148
+ act(() => result.current.removeAttachment(chipId))
149
+ expect(result.current.attachments).toHaveLength(0)
150
+ expect(revoke).toHaveBeenCalled()
151
+ // Never uploaded → no server-side delete attempted.
152
+ expect(uploader.deleteAttachment).not.toHaveBeenCalled()
153
+ revoke.mockRestore()
154
+ })
155
+ })
156
+
157
+ describe('clear() cleanup', () => {
158
+ it('empties all chips and revokes every object url', async () => {
159
+ const revoke = jest.spyOn(URL, 'revokeObjectURL')
160
+ const uploader = fakeUploader()
161
+ const { result } = renderHook(() => useComposerUploads(uploader, 'doc-1'))
162
+ act(() => result.current.attachFiles([file('a.png'), file('b.png')]))
163
+ await waitFor(() => expect(result.current.attachments).toHaveLength(2))
164
+
165
+ act(() => result.current.clear())
166
+ expect(result.current.attachments).toHaveLength(0)
167
+ expect(revoke).toHaveBeenCalledTimes(2)
168
+ revoke.mockRestore()
169
+ })
170
+ })
125
171
  })
@@ -255,9 +255,10 @@ export function useComposerUploads(
255
255
  items.map((it) => ({
256
256
  id: it.id,
257
257
  name: it.name,
258
- // A queued chip reads as uploading (indeterminate) to the user.
259
- status: it.status === 'queued' ? 'uploading' : it.status,
260
- progress: it.status === 'queued' ? 0 : it.progress,
258
+ // A queued chip is parked (waiting for a document), NOT actively
259
+ // uploading — surface it honestly so it doesn't read as a stuck 0% upload.
260
+ status: it.status,
261
+ progress: it.progress,
261
262
  thumbnailUrl: it.thumbnailUrl,
262
263
  sizeBytes: it.sizeBytes,
263
264
  error: it.error,
@@ -8,13 +8,15 @@ import {
8
8
  Controls,
9
9
  type NodeTypes,
10
10
  type EdgeTypes,
11
+ type Connection,
11
12
  } from '@xyflow/react';
12
13
  import { cn } from '../../lib/utils';
13
14
  import { WorkflowNode } from './WorkflowNode';
14
15
  import { WorkflowEdge } from './WorkflowEdge';
15
16
  import { useCanvasGraph } from './hooks/useCanvasGraph';
16
17
  import { useNodeStatusOverlay } from './hooks/useNodeStatusOverlay';
17
- import type { WorkflowGraph, WfExecutionView, NodeTypeDef } from './types';
18
+ import { connectionToEdge } from './graph-ops';
19
+ import type { WorkflowGraph, WfEdge, WfExecutionView, NodeTypeDef } from './types';
18
20
  import type { LayoutDirection } from './layout/auto-layout';
19
21
 
20
22
  const nodeTypes: NodeTypes = { workflowNode: WorkflowNode };
@@ -36,8 +38,23 @@ export interface WorkflowCanvasProps {
36
38
  onRename?: (nodeId: string, name: string) => void;
37
39
  /** Fired when a node is selected on the canvas. */
38
40
  onSelectNode?: (nodeId: string | null) => void;
41
+ /** Fired when an edge is selected on the canvas. */
42
+ onSelectEdge?: (edgeId: string | null) => void;
43
+ /**
44
+ * Currently-selected node id. Threaded onto xyflow's `selected` flag so the
45
+ * controlled canvas knows what's selected — required for keyboard delete.
46
+ */
47
+ selectedNodeId?: string | null;
48
+ /** Currently-selected edge id (same reason as {@link selectedNodeId}). */
49
+ selectedEdgeId?: string | null;
39
50
  /** Drop handler for palette drag-and-drop (slug + client coords). */
40
51
  onDropNodeType?: (slug: string, clientX: number, clientY: number) => void;
52
+ /** Fired when the user draws a connection between two node handles. */
53
+ onConnectEdge?: (edge: WfEdge) => void;
54
+ /** Fired with the ids of nodes removed via select+Delete/Backspace. */
55
+ onDeleteNodes?: (nodeIds: string[]) => void;
56
+ /** Fired with the edges removed via select+Delete/Backspace. */
57
+ onDeleteEdges?: (edges: WfEdge[]) => void;
41
58
  className?: string;
42
59
  }
43
60
 
@@ -48,7 +65,13 @@ function CanvasInner({
48
65
  direction = 'LR',
49
66
  onRename,
50
67
  onSelectNode,
68
+ onSelectEdge,
69
+ selectedNodeId,
70
+ selectedEdgeId,
51
71
  onDropNodeType,
72
+ onConnectEdge,
73
+ onDeleteNodes,
74
+ onDeleteEdges,
52
75
  className,
53
76
  }: WorkflowCanvasProps) {
54
77
  const { nodes: baseNodes, edges: baseEdges } = useCanvasGraph(graph, {
@@ -56,7 +79,32 @@ function CanvasInner({
56
79
  direction,
57
80
  onRename,
58
81
  });
59
- const { nodes, edges } = useNodeStatusOverlay(baseNodes, baseEdges, execution);
82
+ const { nodes: overlaidNodes, edges: overlaidEdges } = useNodeStatusOverlay(
83
+ baseNodes,
84
+ baseEdges,
85
+ execution
86
+ );
87
+ // Thread our selection into xyflow's `selected` flag. The canvas is fully
88
+ // controlled (no onNodesChange), so without this xyflow never marks a
89
+ // clicked node selected and keyboard delete has nothing to remove.
90
+ const nodes = React.useMemo(
91
+ () =>
92
+ selectedNodeId == null
93
+ ? overlaidNodes
94
+ : overlaidNodes.map((n) =>
95
+ n.id === selectedNodeId ? { ...n, selected: true } : n
96
+ ),
97
+ [overlaidNodes, selectedNodeId]
98
+ );
99
+ const edges = React.useMemo(
100
+ () =>
101
+ selectedEdgeId == null
102
+ ? overlaidEdges
103
+ : overlaidEdges.map((e) =>
104
+ e.id === selectedEdgeId ? { ...e, selected: true } : e
105
+ ),
106
+ [overlaidEdges, selectedEdgeId]
107
+ );
60
108
 
61
109
  const handleDragOver = React.useCallback((e: React.DragEvent) => {
62
110
  if (!onDropNodeType) return;
@@ -74,6 +122,17 @@ function CanvasInner({
74
122
  [onDropNodeType]
75
123
  );
76
124
 
125
+ const handleConnect = React.useCallback(
126
+ (conn: Connection) => {
127
+ if (!onConnectEdge || !conn.source || !conn.target) return;
128
+ onConnectEdge(connectionToEdge(conn));
129
+ },
130
+ [onConnectEdge]
131
+ );
132
+
133
+ // Only enable keyboard delete when a delete handler is wired.
134
+ const deletable = !!onDeleteNodes || !!onDeleteEdges;
135
+
77
136
  return (
78
137
  <div
79
138
  className={cn('workflow-canvas h-full w-full', className)}
@@ -87,8 +146,40 @@ function CanvasInner({
87
146
  edgeTypes={edgeTypes}
88
147
  fitView
89
148
  proOptions={{ hideAttribution: true }}
90
- onNodeClick={(_, n) => onSelectNode?.(n.id)}
91
- onPaneClick={() => onSelectNode?.(null)}
149
+ deleteKeyCode={deletable ? ['Backspace', 'Delete'] : null}
150
+ onConnect={onConnectEdge ? handleConnect : undefined}
151
+ onNodesDelete={
152
+ onDeleteNodes
153
+ ? (removed) => onDeleteNodes(removed.map((n) => n.id))
154
+ : undefined
155
+ }
156
+ onEdgesDelete={
157
+ onDeleteEdges
158
+ ? (removed) =>
159
+ onDeleteEdges(
160
+ removed.map((e) =>
161
+ connectionToEdge({
162
+ source: e.source,
163
+ target: e.target,
164
+ sourceHandle: e.sourceHandle,
165
+ targetHandle: e.targetHandle,
166
+ })
167
+ )
168
+ )
169
+ : undefined
170
+ }
171
+ onNodeClick={(_, n) => {
172
+ onSelectNode?.(n.id);
173
+ onSelectEdge?.(null);
174
+ }}
175
+ onEdgeClick={(_, e) => {
176
+ onSelectEdge?.(e.id);
177
+ onSelectNode?.(null);
178
+ }}
179
+ onPaneClick={() => {
180
+ onSelectNode?.(null);
181
+ onSelectEdge?.(null);
182
+ }}
92
183
  >
93
184
  <Background gap={16} />
94
185
  <Controls showInteractive={false} />
@@ -6,7 +6,12 @@ import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace';
6
6
  import { WorkflowCanvas } from './WorkflowCanvas';
7
7
  import { NodePalette } from './NodePalette';
8
8
  import { NodeInspector } from './NodeInspector';
9
- import type { NodeTypeDef, WfNode, WorkflowGraph } from './types';
9
+ import {
10
+ addEdgeToGraph,
11
+ removeEdgesFromGraph,
12
+ removeNodesFromGraph,
13
+ } from './graph-ops';
14
+ import type { NodeTypeDef, WfEdge, WfNode, WorkflowGraph } from './types';
10
15
 
11
16
  export interface WorkflowEditorProps {
12
17
  /** The graph being edited (controlled when `onGraphChange` is provided). */
@@ -39,6 +44,9 @@ export function WorkflowEditor({
39
44
  className,
40
45
  }: WorkflowEditorProps) {
41
46
  const [selectedId, setSelectedId] = React.useState<string | null>(null);
47
+ const [selectedEdgeId, setSelectedEdgeId] = React.useState<string | null>(
48
+ null
49
+ );
42
50
 
43
51
  const typeIndex = React.useMemo(() => {
44
52
  const idx: Record<string, NodeTypeDef> = {};
@@ -89,6 +97,28 @@ export function WorkflowEditor({
89
97
  [graph, onGraphChange, typeIndex]
90
98
  );
91
99
 
100
+ const handleConnectEdge = React.useCallback(
101
+ (edge: WfEdge) => {
102
+ onGraphChange?.(addEdgeToGraph(graph, edge));
103
+ },
104
+ [graph, onGraphChange]
105
+ );
106
+
107
+ const handleDeleteNodes = React.useCallback(
108
+ (nodeIds: string[]) => {
109
+ onGraphChange?.(removeNodesFromGraph(graph, nodeIds));
110
+ if (selectedId && nodeIds.includes(selectedId)) setSelectedId(null);
111
+ },
112
+ [graph, onGraphChange, selectedId]
113
+ );
114
+
115
+ const handleDeleteEdges = React.useCallback(
116
+ (edges: WfEdge[]) => {
117
+ onGraphChange?.(removeEdgesFromGraph(graph, edges));
118
+ },
119
+ [graph, onGraphChange]
120
+ );
121
+
92
122
  const left = (
93
123
  <DualPaneWorkspace
94
124
  leftLabel="Nodes"
@@ -102,7 +132,13 @@ export function WorkflowEditor({
102
132
  nodeTypes={nodeTypes}
103
133
  onRename={handleRename}
104
134
  onSelectNode={setSelectedId}
135
+ onSelectEdge={setSelectedEdgeId}
136
+ selectedNodeId={selectedId}
137
+ selectedEdgeId={selectedEdgeId}
105
138
  onDropNodeType={(slug) => handleAddNode(slug)}
139
+ onConnectEdge={onGraphChange ? handleConnectEdge : undefined}
140
+ onDeleteNodes={onGraphChange ? handleDeleteNodes : undefined}
141
+ onDeleteEdges={onGraphChange ? handleDeleteEdges : undefined}
106
142
  />
107
143
  }
108
144
  />
@@ -0,0 +1,101 @@
1
+ import {
2
+ connectionToEdge,
3
+ addEdgeToGraph,
4
+ removeNodesFromGraph,
5
+ removeEdgesFromGraph,
6
+ } from '../graph-ops';
7
+ import type { WorkflowGraph } from '../types';
8
+
9
+ const graph: WorkflowGraph = {
10
+ nodes: [
11
+ { id: 'a', name: 'A', type: 'trigger.event' },
12
+ { id: 'b', name: 'B', type: 'transform.set' },
13
+ { id: 'c', name: 'C', type: 'action.email' },
14
+ ],
15
+ edges: [
16
+ { source: 'a', target: 'b' },
17
+ { source: 'b', target: 'c', sourceOutput: 1 },
18
+ ],
19
+ };
20
+
21
+ describe('connectionToEdge', () => {
22
+ it('maps handle id strings to numeric output/input indexes', () => {
23
+ expect(
24
+ connectionToEdge({
25
+ source: 'b',
26
+ target: 'c',
27
+ sourceHandle: '1',
28
+ targetHandle: '0',
29
+ })
30
+ ).toEqual({
31
+ source: 'b',
32
+ target: 'c',
33
+ sourceOutput: 1,
34
+ targetInput: 0,
35
+ type: 'main',
36
+ });
37
+ });
38
+
39
+ it('defaults missing/null handles to index 0', () => {
40
+ expect(
41
+ connectionToEdge({ source: 'a', target: 'b', sourceHandle: null })
42
+ ).toEqual({
43
+ source: 'a',
44
+ target: 'b',
45
+ sourceOutput: 0,
46
+ targetInput: 0,
47
+ type: 'main',
48
+ });
49
+ });
50
+ });
51
+
52
+ describe('addEdgeToGraph', () => {
53
+ it('appends a new edge', () => {
54
+ const next = addEdgeToGraph(graph, {
55
+ source: 'a',
56
+ target: 'c',
57
+ sourceOutput: 0,
58
+ targetInput: 0,
59
+ });
60
+ expect(next.edges).toHaveLength(3);
61
+ expect(next.edges).toContainEqual(
62
+ expect.objectContaining({ source: 'a', target: 'c' })
63
+ );
64
+ // does not mutate the input
65
+ expect(graph.edges).toHaveLength(2);
66
+ });
67
+
68
+ it('is a no-op for a duplicate edge (same source/output/target/input)', () => {
69
+ const next = addEdgeToGraph(graph, {
70
+ source: 'a',
71
+ target: 'b',
72
+ sourceOutput: 0,
73
+ targetInput: 0,
74
+ });
75
+ expect(next.edges).toHaveLength(2);
76
+ });
77
+ });
78
+
79
+ describe('removeNodesFromGraph', () => {
80
+ it('removes the node AND every incident edge (as source or target)', () => {
81
+ const next = removeNodesFromGraph(graph, ['b']);
82
+ expect(next.nodes.map((n) => n.id)).toEqual(['a', 'c']);
83
+ // a->b and b->c both reference b, so both go
84
+ expect(next.edges).toHaveLength(0);
85
+ });
86
+
87
+ it('keeps edges between surviving nodes', () => {
88
+ const next = removeNodesFromGraph(graph, ['c']);
89
+ expect(next.nodes.map((n) => n.id)).toEqual(['a', 'b']);
90
+ expect(next.edges).toEqual([{ source: 'a', target: 'b' }]);
91
+ });
92
+ });
93
+
94
+ describe('removeEdgesFromGraph', () => {
95
+ it('removes only the matching edge (by source/target/output/input)', () => {
96
+ const next = removeEdgesFromGraph(graph, [
97
+ { source: 'b', target: 'c', sourceOutput: 1, targetInput: 0 },
98
+ ]);
99
+ expect(next.edges).toEqual([{ source: 'a', target: 'b' }]);
100
+ });
101
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Pure graph-mutation helpers for the workflow editor.
3
+ *
4
+ * The editor canvas is controlled — every mutation produces a NEW
5
+ * {@link WorkflowGraph} (never mutates the input) which the consumer feeds
6
+ * back through `onGraphChange`. Kept framework-free and side-effect-free so
7
+ * they're trivially unit-testable and reusable outside React.
8
+ */
9
+
10
+ import type { WfEdge, WorkflowGraph } from './types';
11
+
12
+ const DEFAULT_CONNECTION_TYPE = 'main';
13
+
14
+ /** A minimal xyflow `Connection`-like shape (avoids an @xyflow/react import). */
15
+ export interface GraphConnection {
16
+ source: string;
17
+ target: string;
18
+ sourceHandle?: string | null;
19
+ targetHandle?: string | null;
20
+ }
21
+
22
+ /** Parse a handle id ("0", "1", null, "") into its numeric index (default 0). */
23
+ function handleIndex(handle: string | null | undefined): number {
24
+ if (handle === null || handle === undefined || handle === '') return 0;
25
+ const n = Number.parseInt(handle, 10);
26
+ return Number.isNaN(n) ? 0 : n;
27
+ }
28
+
29
+ /** True when two edges connect the same source-output → target-input. */
30
+ function sameEdge(a: WfEdge, b: WfEdge): boolean {
31
+ return (
32
+ a.source === b.source &&
33
+ a.target === b.target &&
34
+ (a.sourceOutput ?? 0) === (b.sourceOutput ?? 0) &&
35
+ (a.targetInput ?? 0) === (b.targetInput ?? 0)
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Map an xyflow {@link GraphConnection} (handle ids are output/input index
41
+ * strings) to a logical {@link WfEdge}.
42
+ */
43
+ export function connectionToEdge(conn: GraphConnection): WfEdge {
44
+ return {
45
+ source: conn.source,
46
+ target: conn.target,
47
+ sourceOutput: handleIndex(conn.sourceHandle),
48
+ targetInput: handleIndex(conn.targetHandle),
49
+ type: DEFAULT_CONNECTION_TYPE,
50
+ };
51
+ }
52
+
53
+ /** Append an edge, ignoring exact duplicates. Returns a new graph. */
54
+ export function addEdgeToGraph(
55
+ graph: WorkflowGraph,
56
+ edge: WfEdge
57
+ ): WorkflowGraph {
58
+ if (graph.edges.some((e) => sameEdge(e, edge))) return graph;
59
+ return { ...graph, edges: [...graph.edges, edge] };
60
+ }
61
+
62
+ /**
63
+ * Remove the given nodes AND every edge incident to them (as source or
64
+ * target). Returns a new graph.
65
+ */
66
+ export function removeNodesFromGraph(
67
+ graph: WorkflowGraph,
68
+ nodeIds: string[]
69
+ ): WorkflowGraph {
70
+ const dropped = new Set(nodeIds);
71
+ return {
72
+ ...graph,
73
+ nodes: graph.nodes.filter((n) => !dropped.has(n.id)),
74
+ edges: graph.edges.filter(
75
+ (e) => !dropped.has(e.source) && !dropped.has(e.target)
76
+ ),
77
+ };
78
+ }
79
+
80
+ /** Remove the given edges (matched by source/target/output/input). New graph. */
81
+ export function removeEdgesFromGraph(
82
+ graph: WorkflowGraph,
83
+ edges: WfEdge[]
84
+ ): WorkflowGraph {
85
+ return {
86
+ ...graph,
87
+ edges: graph.edges.filter((e) => !edges.some((d) => sameEdge(e, d))),
88
+ };
89
+ }
@@ -39,6 +39,15 @@ export type { FlowNode, FlowEdge } from './serialization';
39
39
  export { autoLayout, layoutLR, layoutTB } from './layout/auto-layout';
40
40
  export type { LayoutDirection, AutoLayoutOptions } from './layout/auto-layout';
41
41
 
42
+ // Graph mutation (pure helpers used by the editor; also reusable standalone)
43
+ export {
44
+ connectionToEdge,
45
+ addEdgeToGraph,
46
+ removeNodesFromGraph,
47
+ removeEdgesFromGraph,
48
+ } from './graph-ops';
49
+ export type { GraphConnection } from './graph-ops';
50
+
42
51
  // Theme
43
52
  export {
44
53
  CATEGORY_TOKENS,