@startsimpli/ui 0.4.14 → 0.4.16
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/README.md +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +62 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
- package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
- package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
- package/src/components/team/pages/index.ts +33 -0
- package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
- package/src/components/team/pages/types.ts +135 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Handle, Position, type NodeProps } from '@xyflow/react';
|
|
5
|
+
import { cn } from '../../lib/utils';
|
|
6
|
+
import { StatusBadge } from '../badge/StatusBadge';
|
|
7
|
+
import { getCategoryToken } from './theme/categories';
|
|
8
|
+
import { getCategoryIcon } from './node-icons';
|
|
9
|
+
import { NODE_STATUS_BADGE } from './exec-status';
|
|
10
|
+
import type { NodeStatus, WfNode } from './types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Data payload carried on an xyflow node rendered by {@link WorkflowNode}.
|
|
14
|
+
* The serializer stores the canonical {@link WfNode} under `node`; the canvas
|
|
15
|
+
* layers presentation extras (category, status overlay, port labels, rename
|
|
16
|
+
* callback) on top.
|
|
17
|
+
*/
|
|
18
|
+
export interface WorkflowNodeData {
|
|
19
|
+
/** Canonical graph node. */
|
|
20
|
+
node: WfNode;
|
|
21
|
+
/** Category token key (resolved via theme/categories). */
|
|
22
|
+
category?: string;
|
|
23
|
+
/** Runtime status overlay (defaults to "idle" in the editor). */
|
|
24
|
+
status?: NodeStatus;
|
|
25
|
+
/** Number of inputs (default 1). */
|
|
26
|
+
inputCount?: number;
|
|
27
|
+
/** Number of outputs (default 1). */
|
|
28
|
+
outputCount?: number;
|
|
29
|
+
/** Labels for each output port (e.g. ["true", "false"]). */
|
|
30
|
+
outputLabels?: string[];
|
|
31
|
+
/** Labels for each input port. */
|
|
32
|
+
inputLabels?: string[];
|
|
33
|
+
/** Called when the node is renamed inline. */
|
|
34
|
+
onRename?: (nodeId: string, name: string) => void;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build evenly-spaced top offsets (as %) for `count` handles. */
|
|
39
|
+
function handleOffsets(count: number): string[] {
|
|
40
|
+
if (count <= 1) return ['50%'];
|
|
41
|
+
return Array.from({ length: count }, (_, i) =>
|
|
42
|
+
`${((i + 1) / (count + 1)) * 100}%`
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Custom xyflow node for the workflow editor: category accent color + icon,
|
|
48
|
+
* an editable title, labelled input/output ports (handles), a runtime status
|
|
49
|
+
* badge, and disabled styling. Renaming is inline (double-click the title)
|
|
50
|
+
* and surfaced via `data.onRename`.
|
|
51
|
+
*/
|
|
52
|
+
export function WorkflowNode({ id, data, selected }: NodeProps) {
|
|
53
|
+
const d = data as WorkflowNodeData;
|
|
54
|
+
const node = d.node;
|
|
55
|
+
const token = getCategoryToken(d.category ?? deriveCategory(node.type));
|
|
56
|
+
const Icon = getCategoryIcon(token.icon);
|
|
57
|
+
const status: NodeStatus = d.status ?? 'idle';
|
|
58
|
+
const disabled = Boolean(node.disabled);
|
|
59
|
+
|
|
60
|
+
const inputCount = d.inputCount ?? 1;
|
|
61
|
+
const outputCount = d.outputCount ?? 1;
|
|
62
|
+
const inputOffsets = handleOffsets(inputCount);
|
|
63
|
+
const outputOffsets = handleOffsets(outputCount);
|
|
64
|
+
|
|
65
|
+
const [editing, setEditing] = React.useState(false);
|
|
66
|
+
const [draft, setDraft] = React.useState(node.name);
|
|
67
|
+
React.useEffect(() => {
|
|
68
|
+
setDraft(node.name);
|
|
69
|
+
}, [node.name]);
|
|
70
|
+
|
|
71
|
+
const commit = () => {
|
|
72
|
+
setEditing(false);
|
|
73
|
+
const next = draft.trim();
|
|
74
|
+
if (next && next !== node.name) {
|
|
75
|
+
d.onRename?.(id, next);
|
|
76
|
+
} else {
|
|
77
|
+
setDraft(node.name);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
data-node-id={id}
|
|
84
|
+
data-status={status}
|
|
85
|
+
data-disabled={disabled ? 'true' : undefined}
|
|
86
|
+
className={cn(
|
|
87
|
+
'relative min-w-[180px] rounded-lg border-2 bg-card shadow-sm transition-shadow',
|
|
88
|
+
selected && 'ring-2 ring-offset-1',
|
|
89
|
+
disabled && 'opacity-50 grayscale'
|
|
90
|
+
)}
|
|
91
|
+
style={{
|
|
92
|
+
borderColor: token.color,
|
|
93
|
+
// selection ring picks up the category accent
|
|
94
|
+
['--tw-ring-color' as string]: token.color,
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
{/* Input handles */}
|
|
98
|
+
{inputOffsets.map((top, i) => (
|
|
99
|
+
<React.Fragment key={`in-${i}`}>
|
|
100
|
+
<Handle
|
|
101
|
+
id={String(i)}
|
|
102
|
+
type="target"
|
|
103
|
+
position={Position.Left}
|
|
104
|
+
data-handle-kind="input"
|
|
105
|
+
style={{ top, background: token.color }}
|
|
106
|
+
/>
|
|
107
|
+
{d.inputLabels?.[i] && (
|
|
108
|
+
<span
|
|
109
|
+
className="absolute left-2 -translate-y-1/2 text-[10px] text-muted-foreground"
|
|
110
|
+
style={{ top }}
|
|
111
|
+
>
|
|
112
|
+
{d.inputLabels[i]}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
</React.Fragment>
|
|
116
|
+
))}
|
|
117
|
+
|
|
118
|
+
{/* Header: icon + category label */}
|
|
119
|
+
<div
|
|
120
|
+
className="flex items-center gap-1.5 rounded-t-md px-2.5 py-1"
|
|
121
|
+
style={{ background: token.background, color: token.foreground }}
|
|
122
|
+
>
|
|
123
|
+
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
124
|
+
<span className="text-[11px] font-medium uppercase tracking-wide">
|
|
125
|
+
{token.label}
|
|
126
|
+
</span>
|
|
127
|
+
<StatusBadge
|
|
128
|
+
status={status}
|
|
129
|
+
config={NODE_STATUS_BADGE}
|
|
130
|
+
size="sm"
|
|
131
|
+
className="ml-auto"
|
|
132
|
+
/>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Title (inline-editable) */}
|
|
136
|
+
<div className="px-2.5 py-2">
|
|
137
|
+
{editing ? (
|
|
138
|
+
<input
|
|
139
|
+
autoFocus
|
|
140
|
+
role="textbox"
|
|
141
|
+
aria-label="Node name"
|
|
142
|
+
value={draft}
|
|
143
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
144
|
+
onBlur={commit}
|
|
145
|
+
onKeyDown={(e) => {
|
|
146
|
+
if (e.key === 'Enter') commit();
|
|
147
|
+
if (e.key === 'Escape') {
|
|
148
|
+
setDraft(node.name);
|
|
149
|
+
setEditing(false);
|
|
150
|
+
}
|
|
151
|
+
}}
|
|
152
|
+
className="w-full rounded border border-border bg-background px-1 py-0.5 text-sm font-medium text-foreground outline-none"
|
|
153
|
+
/>
|
|
154
|
+
) : (
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
onDoubleClick={() => d.onRename && setEditing(true)}
|
|
158
|
+
className="block w-full truncate text-left text-sm font-medium text-foreground"
|
|
159
|
+
title={node.name}
|
|
160
|
+
>
|
|
161
|
+
{node.name}
|
|
162
|
+
</button>
|
|
163
|
+
)}
|
|
164
|
+
<span className="mt-0.5 block truncate text-[11px] text-muted-foreground">
|
|
165
|
+
{node.type}
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Output handles + labels */}
|
|
170
|
+
{outputOffsets.map((top, i) => (
|
|
171
|
+
<React.Fragment key={`out-${i}`}>
|
|
172
|
+
<Handle
|
|
173
|
+
id={String(i)}
|
|
174
|
+
type="source"
|
|
175
|
+
position={Position.Right}
|
|
176
|
+
data-handle-kind="output"
|
|
177
|
+
style={{ top, background: token.color }}
|
|
178
|
+
/>
|
|
179
|
+
{d.outputLabels?.[i] && (
|
|
180
|
+
<span
|
|
181
|
+
className="absolute right-2 -translate-y-1/2 text-[10px] text-muted-foreground"
|
|
182
|
+
style={{ top }}
|
|
183
|
+
>
|
|
184
|
+
{d.outputLabels[i]}
|
|
185
|
+
</span>
|
|
186
|
+
)}
|
|
187
|
+
</React.Fragment>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Best-effort category from a "category.subtype" slug (e.g. "action.email"). */
|
|
194
|
+
function deriveCategory(type: string | undefined): string | undefined {
|
|
195
|
+
if (!type) return undefined;
|
|
196
|
+
const head = type.split('.')[0];
|
|
197
|
+
return head || undefined;
|
|
198
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { StatusBadge } from '../badge/StatusBadge';
|
|
6
|
+
import { DualPaneWorkspace } from '../workspace/DualPaneWorkspace';
|
|
7
|
+
import { ExecutionTimeline } from './ExecutionTimeline';
|
|
8
|
+
import { ExecNodeDetails } from './ExecNodeDetails';
|
|
9
|
+
import { RUN_STATUS_BADGE } from './exec-status';
|
|
10
|
+
import type { NodeExecView, WfExecutionView } from './types';
|
|
11
|
+
|
|
12
|
+
export interface WorkflowRunViewerProps {
|
|
13
|
+
/** The current run snapshot. Pass a fresh object on each poll. */
|
|
14
|
+
execution: WfExecutionView;
|
|
15
|
+
/** Initially-selected node id. */
|
|
16
|
+
initialNodeId?: string;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function nodeList(execution: WfExecutionView): NodeExecView[] {
|
|
21
|
+
return Object.values(execution.nodes ?? {});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read-only run visualizer: an execution timeline beside a selected-node
|
|
26
|
+
* detail panel (built on {@link DualPaneWorkspace}), with a run-status
|
|
27
|
+
* header badge. Designed for polling — pass a fresh {@link WfExecutionView}
|
|
28
|
+
* snapshot on each tick and the timeline + currently-selected detail panel
|
|
29
|
+
* re-render against the latest data.
|
|
30
|
+
*/
|
|
31
|
+
export function WorkflowRunViewer({
|
|
32
|
+
execution,
|
|
33
|
+
initialNodeId,
|
|
34
|
+
className,
|
|
35
|
+
}: WorkflowRunViewerProps) {
|
|
36
|
+
const nodes = nodeList(execution);
|
|
37
|
+
const [selectedId, setSelectedId] = React.useState<string | null>(
|
|
38
|
+
initialNodeId ?? null
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
// Resolve the selected node against the LATEST snapshot so the detail
|
|
42
|
+
// panel reflects new poll data without needing to re-select.
|
|
43
|
+
const selectedNode = selectedId != null ? execution.nodes?.[selectedId] : null;
|
|
44
|
+
|
|
45
|
+
const header = (
|
|
46
|
+
<div className="flex items-center justify-between gap-3 px-3 py-2">
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<span className="text-sm font-semibold text-foreground">
|
|
49
|
+
Workflow run
|
|
50
|
+
</span>
|
|
51
|
+
{execution.executionId && (
|
|
52
|
+
<span className="text-xs text-muted-foreground">
|
|
53
|
+
{execution.executionId}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
<StatusBadge status={execution.status} config={RUN_STATUS_BADGE} size="sm" />
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className={cn('flex h-full min-h-0 w-full flex-col', className)}>
|
|
63
|
+
<DualPaneWorkspace
|
|
64
|
+
toolbar={header}
|
|
65
|
+
leftLabel="Timeline"
|
|
66
|
+
rightLabel="Node detail"
|
|
67
|
+
initialLeftSize={42}
|
|
68
|
+
left={
|
|
69
|
+
<div className="h-full min-h-0 overflow-auto">
|
|
70
|
+
<ExecutionTimeline
|
|
71
|
+
nodeExecutions={nodes}
|
|
72
|
+
activeNodeId={selectedId ?? undefined}
|
|
73
|
+
onSelectNode={setSelectedId}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
}
|
|
77
|
+
right={<ExecNodeDetails node={selectedNode} />}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { render, screen, fireEvent, within } from '@testing-library/react';
|
|
2
|
+
import { ExecutionTimeline } from '../ExecutionTimeline';
|
|
3
|
+
import type { NodeExecView } from '../types';
|
|
4
|
+
|
|
5
|
+
const nodes: NodeExecView[] = [
|
|
6
|
+
{
|
|
7
|
+
nodeId: 'a',
|
|
8
|
+
name: 'Fetch data',
|
|
9
|
+
type: 'trigger.event',
|
|
10
|
+
status: 'success',
|
|
11
|
+
executionOrder: 0,
|
|
12
|
+
executionTimeMs: 1234,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
nodeId: 'b',
|
|
16
|
+
name: 'Transform',
|
|
17
|
+
type: 'transform.set',
|
|
18
|
+
status: 'running',
|
|
19
|
+
executionOrder: 1,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
nodeId: 'c',
|
|
23
|
+
name: 'Notify',
|
|
24
|
+
type: 'action.http',
|
|
25
|
+
status: 'error',
|
|
26
|
+
executionOrder: 2,
|
|
27
|
+
executionTimeMs: 42,
|
|
28
|
+
error: 'Connection refused',
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
describe('ExecutionTimeline', () => {
|
|
33
|
+
it('renders one row per node execution, in order', () => {
|
|
34
|
+
render(<ExecutionTimeline nodeExecutions={nodes} />);
|
|
35
|
+
const rows = screen.getAllByRole('listitem');
|
|
36
|
+
expect(rows).toHaveLength(3);
|
|
37
|
+
expect(rows[0]).toHaveTextContent('Fetch data');
|
|
38
|
+
expect(rows[1]).toHaveTextContent('Transform');
|
|
39
|
+
expect(rows[2]).toHaveTextContent('Notify');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('renders a status badge per node with the correct label', () => {
|
|
43
|
+
render(<ExecutionTimeline nodeExecutions={nodes} />);
|
|
44
|
+
expect(screen.getByText('Success')).toBeInTheDocument();
|
|
45
|
+
expect(screen.getByText('Running')).toBeInTheDocument();
|
|
46
|
+
expect(screen.getByText('Error')).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('renders human-readable durations', () => {
|
|
50
|
+
render(<ExecutionTimeline nodeExecutions={nodes} />);
|
|
51
|
+
expect(screen.getByText('1.23s')).toBeInTheDocument();
|
|
52
|
+
expect(screen.getByText('42ms')).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('renders inline error text for error nodes', () => {
|
|
56
|
+
render(<ExecutionTimeline nodeExecutions={nodes} />);
|
|
57
|
+
expect(screen.getByText('Connection refused')).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('shows a live/pulsing indicator only for the running node', () => {
|
|
61
|
+
const { container } = render(<ExecutionTimeline nodeExecutions={nodes} />);
|
|
62
|
+
const live = container.querySelectorAll('[data-live="true"]');
|
|
63
|
+
expect(live).toHaveLength(1);
|
|
64
|
+
const rows = screen.getAllByRole('listitem');
|
|
65
|
+
// running node is the second row (b)
|
|
66
|
+
expect(within(rows[1]).getByText('Running')).toBeInTheDocument();
|
|
67
|
+
expect(rows[1].querySelector('[data-live="true"]')).not.toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not show a live indicator for terminal statuses', () => {
|
|
71
|
+
const terminal: NodeExecView[] = [
|
|
72
|
+
{ nodeId: 'a', name: 'A', type: 't', status: 'success', executionOrder: 0 },
|
|
73
|
+
{ nodeId: 'b', name: 'B', type: 't', status: 'error', executionOrder: 1 },
|
|
74
|
+
{ nodeId: 'c', name: 'C', type: 't', status: 'skipped', executionOrder: 2 },
|
|
75
|
+
];
|
|
76
|
+
const { container } = render(<ExecutionTimeline nodeExecutions={terminal} />);
|
|
77
|
+
expect(container.querySelectorAll('[data-live="true"]')).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('calls onSelectNode when a row is clicked', () => {
|
|
81
|
+
const onSelectNode = jest.fn();
|
|
82
|
+
render(<ExecutionTimeline nodeExecutions={nodes} onSelectNode={onSelectNode} />);
|
|
83
|
+
fireEvent.click(screen.getByText('Transform'));
|
|
84
|
+
expect(onSelectNode).toHaveBeenCalledWith('b');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('marks the active node row as selected', () => {
|
|
88
|
+
render(<ExecutionTimeline nodeExecutions={nodes} activeNodeId="c" />);
|
|
89
|
+
const rows = screen.getAllByRole('listitem');
|
|
90
|
+
expect(rows[2]).toHaveAttribute('aria-current', 'true');
|
|
91
|
+
expect(rows[0]).not.toHaveAttribute('aria-current', 'true');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('renders an empty state when there are no executions', () => {
|
|
95
|
+
render(<ExecutionTimeline nodeExecutions={[]} />);
|
|
96
|
+
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
|
|
97
|
+
expect(screen.getByText('No node executions')).toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { NodeInspector } from '../NodeInspector';
|
|
4
|
+
import type { WfNode, NodeTypeDef, NodeExecView } from '../types';
|
|
5
|
+
|
|
6
|
+
const node: WfNode = {
|
|
7
|
+
id: 'n1',
|
|
8
|
+
name: 'Send email',
|
|
9
|
+
type: 'action.email',
|
|
10
|
+
parameters: { to: 'a@b.com', retries: 3, urgent: true },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const nodeType: NodeTypeDef & { parameterSchema?: Record<string, unknown> } = {
|
|
14
|
+
slug: 'action.email',
|
|
15
|
+
name: 'Send email',
|
|
16
|
+
category: 'action',
|
|
17
|
+
parameterSchema: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
properties: {
|
|
20
|
+
to: { type: 'string', title: 'Recipient' },
|
|
21
|
+
retries: { type: 'number', title: 'Retries' },
|
|
22
|
+
urgent: { type: 'boolean', title: 'Urgent' },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe('NodeInspector', () => {
|
|
28
|
+
it('renders a field per schema property with current values', () => {
|
|
29
|
+
render(<NodeInspector node={node} nodeType={nodeType} />);
|
|
30
|
+
expect(screen.getByLabelText('Recipient')).toHaveValue('a@b.com');
|
|
31
|
+
expect(screen.getByLabelText('Retries')).toHaveValue(3);
|
|
32
|
+
expect(screen.getByLabelText('Urgent')).toBeChecked();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('emits parameter changes via onParametersChange', () => {
|
|
36
|
+
const onParametersChange = jest.fn();
|
|
37
|
+
render(
|
|
38
|
+
<NodeInspector
|
|
39
|
+
node={node}
|
|
40
|
+
nodeType={nodeType}
|
|
41
|
+
onParametersChange={onParametersChange}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
fireEvent.change(screen.getByLabelText('Recipient'), {
|
|
45
|
+
target: { value: 'c@d.com' },
|
|
46
|
+
});
|
|
47
|
+
expect(onParametersChange).toHaveBeenCalledWith(
|
|
48
|
+
'n1',
|
|
49
|
+
expect.objectContaining({ to: 'c@d.com' })
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders an empty state when no node is selected', () => {
|
|
54
|
+
render(<NodeInspector node={null} />);
|
|
55
|
+
expect(screen.getByText(/select a node/i)).toBeInTheDocument();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('shows read-only input/output tabs when given an execution view', async () => {
|
|
59
|
+
const exec: NodeExecView = {
|
|
60
|
+
nodeId: 'n1',
|
|
61
|
+
status: 'success',
|
|
62
|
+
inputData: { foo: 'in' },
|
|
63
|
+
outputData: { foo: 'out' },
|
|
64
|
+
};
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
render(<NodeInspector node={node} nodeType={nodeType} execution={exec} />);
|
|
67
|
+
// the exec tabs exist
|
|
68
|
+
expect(screen.getByRole('tab', { name: /input/i })).toBeInTheDocument();
|
|
69
|
+
expect(screen.getByRole('tab', { name: /output/i })).toBeInTheDocument();
|
|
70
|
+
// switch to the Output tab
|
|
71
|
+
await user.click(screen.getByRole('tab', { name: /output/i }));
|
|
72
|
+
expect(screen.getByText(/"foo": "out"/)).toBeInTheDocument();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { NodePalette } from '../NodePalette';
|
|
3
|
+
import type { NodeTypeDef } from '../types';
|
|
4
|
+
|
|
5
|
+
const nodeTypes: NodeTypeDef[] = [
|
|
6
|
+
{ slug: 'trigger.event', name: 'Event trigger', category: 'trigger' },
|
|
7
|
+
{ slug: 'action.email', name: 'Send email', category: 'action' },
|
|
8
|
+
{ slug: 'action.http', name: 'HTTP request', category: 'action' },
|
|
9
|
+
{ slug: 'condition.if', name: 'If', category: 'condition' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe('NodePalette', () => {
|
|
13
|
+
it('lists all node types grouped by category', () => {
|
|
14
|
+
render(<NodePalette nodeTypes={nodeTypes} />);
|
|
15
|
+
expect(screen.getByText('Event trigger')).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByText('Send email')).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText('HTTP request')).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByText('If')).toBeInTheDocument();
|
|
19
|
+
// group headings (category labels)
|
|
20
|
+
expect(screen.getByText('Trigger')).toBeInTheDocument();
|
|
21
|
+
expect(screen.getByText('Action')).toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('filters node types by search query', () => {
|
|
25
|
+
render(<NodePalette nodeTypes={nodeTypes} />);
|
|
26
|
+
const search = screen.getByRole('searchbox');
|
|
27
|
+
fireEvent.change(search, { target: { value: 'email' } });
|
|
28
|
+
expect(screen.getByText('Send email')).toBeInTheDocument();
|
|
29
|
+
expect(screen.queryByText('HTTP request')).not.toBeInTheDocument();
|
|
30
|
+
expect(screen.queryByText('If')).not.toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('sets the node type slug on dataTransfer when dragging', () => {
|
|
34
|
+
render(<NodePalette nodeTypes={nodeTypes} />);
|
|
35
|
+
const item = screen.getByText('Send email').closest('[draggable="true"]');
|
|
36
|
+
expect(item).not.toBeNull();
|
|
37
|
+
const setData = jest.fn();
|
|
38
|
+
fireEvent.dragStart(item as Element, {
|
|
39
|
+
dataTransfer: { setData, effectAllowed: '' },
|
|
40
|
+
});
|
|
41
|
+
expect(setData).toHaveBeenCalledWith(
|
|
42
|
+
'application/x-workflow-node',
|
|
43
|
+
'action.email'
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { WorkflowCanvas } from '../WorkflowCanvas';
|
|
3
|
+
import type { WorkflowGraph, WfExecutionView } from '../types';
|
|
4
|
+
|
|
5
|
+
// xyflow + DualPaneWorkspace consumers rely on browser APIs jsdom omits.
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
8
|
+
writable: true,
|
|
9
|
+
value: (query: string) => ({
|
|
10
|
+
matches: false,
|
|
11
|
+
media: query,
|
|
12
|
+
onchange: null,
|
|
13
|
+
addEventListener: () => {},
|
|
14
|
+
removeEventListener: () => {},
|
|
15
|
+
addListener: () => {},
|
|
16
|
+
removeListener: () => {},
|
|
17
|
+
dispatchEvent: () => false,
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
// xyflow measures nodes via ResizeObserver.
|
|
21
|
+
global.ResizeObserver = class {
|
|
22
|
+
observe() {}
|
|
23
|
+
unobserve() {}
|
|
24
|
+
disconnect() {}
|
|
25
|
+
} as never;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const graph: WorkflowGraph = {
|
|
29
|
+
id: 'wf1',
|
|
30
|
+
name: 'Sample',
|
|
31
|
+
nodes: [
|
|
32
|
+
{ id: 'a', name: 'Start run', type: 'trigger.event', position: { x: 0, y: 0 } },
|
|
33
|
+
{ id: 'b', name: 'Email user', type: 'action.email', position: { x: 0, y: 120 } },
|
|
34
|
+
],
|
|
35
|
+
edges: [{ source: 'a', target: 'b' }],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('WorkflowCanvas', () => {
|
|
39
|
+
it('renders a node per graph node', () => {
|
|
40
|
+
render(<WorkflowCanvas graph={graph} />);
|
|
41
|
+
expect(screen.getByText('Start run')).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByText('Email user')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('applies a status overlay from a WfExecutionView', () => {
|
|
46
|
+
const execution: WfExecutionView = {
|
|
47
|
+
executionId: 'e1',
|
|
48
|
+
status: 'running',
|
|
49
|
+
nodes: {
|
|
50
|
+
a: { nodeId: 'a', status: 'success' },
|
|
51
|
+
b: { nodeId: 'b', status: 'running' },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
render(<WorkflowCanvas graph={graph} execution={execution} />);
|
|
55
|
+
// node b shows the running badge, node a shows success
|
|
56
|
+
expect(screen.getByText('Running')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText('Success')).toBeInTheDocument();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { ReactFlowProvider } from '@xyflow/react';
|
|
3
|
+
import { WorkflowNode } from '../WorkflowNode';
|
|
4
|
+
import type { WfNode } from '../types';
|
|
5
|
+
|
|
6
|
+
// xyflow handles need a provider + measured store; render inside one.
|
|
7
|
+
function renderNode(props: Parameters<typeof WorkflowNode>[0]) {
|
|
8
|
+
return render(
|
|
9
|
+
<ReactFlowProvider>
|
|
10
|
+
<WorkflowNode {...props} />
|
|
11
|
+
</ReactFlowProvider>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function baseData(overrides: Partial<WfNode> = {}) {
|
|
16
|
+
const node: WfNode = {
|
|
17
|
+
id: 'n1',
|
|
18
|
+
name: 'Send email',
|
|
19
|
+
type: 'action.email',
|
|
20
|
+
...overrides,
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
node,
|
|
24
|
+
category: 'action',
|
|
25
|
+
status: 'idle' as const,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('WorkflowNode', () => {
|
|
30
|
+
it('renders the node title', () => {
|
|
31
|
+
renderNode({ id: 'n1', data: baseData(), selected: false } as never);
|
|
32
|
+
expect(screen.getByText('Send email')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('renders a status badge reflecting the node status', () => {
|
|
36
|
+
renderNode({
|
|
37
|
+
id: 'n1',
|
|
38
|
+
data: { ...baseData(), status: 'running' },
|
|
39
|
+
selected: false,
|
|
40
|
+
} as never);
|
|
41
|
+
expect(screen.getByText('Running')).toBeInTheDocument();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('renders the category label from the theme', () => {
|
|
45
|
+
renderNode({ id: 'n1', data: baseData(), selected: false } as never);
|
|
46
|
+
// action category resolves to label "Action"
|
|
47
|
+
expect(screen.getByText('Action')).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('renders one output handle per output (multi-output: if/else)', () => {
|
|
51
|
+
const { container } = renderNode({
|
|
52
|
+
id: 'n1',
|
|
53
|
+
data: {
|
|
54
|
+
...baseData({ type: 'condition.if' }),
|
|
55
|
+
category: 'condition',
|
|
56
|
+
outputLabels: ['true', 'false'],
|
|
57
|
+
outputCount: 2,
|
|
58
|
+
},
|
|
59
|
+
selected: false,
|
|
60
|
+
} as never);
|
|
61
|
+
const outputs = container.querySelectorAll('[data-handle-kind="output"]');
|
|
62
|
+
expect(outputs).toHaveLength(2);
|
|
63
|
+
expect(screen.getByText('true')).toBeInTheDocument();
|
|
64
|
+
expect(screen.getByText('false')).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('applies disabled styling when disabled', () => {
|
|
68
|
+
const { container } = renderNode({
|
|
69
|
+
id: 'n1',
|
|
70
|
+
data: { ...baseData({ disabled: true }) },
|
|
71
|
+
selected: false,
|
|
72
|
+
} as never);
|
|
73
|
+
expect(
|
|
74
|
+
container.querySelector('[data-disabled="true"]')
|
|
75
|
+
).toBeInTheDocument();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('invokes onRename when the title is edited', () => {
|
|
79
|
+
const onRename = jest.fn();
|
|
80
|
+
renderNode({
|
|
81
|
+
id: 'n1',
|
|
82
|
+
data: { ...baseData(), onRename },
|
|
83
|
+
selected: true,
|
|
84
|
+
} as never);
|
|
85
|
+
// double-click the title to enter edit mode
|
|
86
|
+
fireEvent.doubleClick(screen.getByText('Send email'));
|
|
87
|
+
const input = screen.getByRole('textbox');
|
|
88
|
+
fireEvent.change(input, { target: { value: 'Send welcome email' } });
|
|
89
|
+
fireEvent.blur(input);
|
|
90
|
+
expect(onRename).toHaveBeenCalledWith('n1', 'Send welcome email');
|
|
91
|
+
});
|
|
92
|
+
});
|