@startsimpli/ui 0.4.14 → 0.4.15
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/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -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/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,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
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { WorkflowRunViewer } from '../WorkflowRunViewer';
|
|
3
|
+
import { ExecNodeDetails } from '../ExecNodeDetails';
|
|
4
|
+
import type { WfExecutionView, NodeExecView } from '../types';
|
|
5
|
+
|
|
6
|
+
// jsdom lacks matchMedia, which DualPaneWorkspace uses for its `auto` layout.
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
9
|
+
writable: true,
|
|
10
|
+
value: (query: string) => ({
|
|
11
|
+
matches: false,
|
|
12
|
+
media: query,
|
|
13
|
+
onchange: null,
|
|
14
|
+
addEventListener: () => {},
|
|
15
|
+
removeEventListener: () => {},
|
|
16
|
+
addListener: () => {},
|
|
17
|
+
removeListener: () => {},
|
|
18
|
+
dispatchEvent: () => false,
|
|
19
|
+
}),
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function makeExecution(overrides: Partial<WfExecutionView> = {}): WfExecutionView {
|
|
24
|
+
const nodeA: NodeExecView = {
|
|
25
|
+
nodeId: 'a',
|
|
26
|
+
name: 'Fetch data',
|
|
27
|
+
type: 'trigger.event',
|
|
28
|
+
status: 'success',
|
|
29
|
+
executionOrder: 0,
|
|
30
|
+
executionTimeMs: 1000,
|
|
31
|
+
inputData: { foo: 'in' },
|
|
32
|
+
outputData: { foo: 'out' },
|
|
33
|
+
};
|
|
34
|
+
const nodeB: NodeExecView = {
|
|
35
|
+
nodeId: 'b',
|
|
36
|
+
name: 'Transform',
|
|
37
|
+
type: 'transform.set',
|
|
38
|
+
status: 'running',
|
|
39
|
+
executionOrder: 1,
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
executionId: 'exec-1',
|
|
43
|
+
status: 'running',
|
|
44
|
+
nodes: { a: nodeA, b: nodeB },
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('WorkflowRunViewer', () => {
|
|
50
|
+
it('renders the run status header badge', () => {
|
|
51
|
+
render(<WorkflowRunViewer execution={makeExecution({ status: 'completed' })} />);
|
|
52
|
+
expect(screen.getByText('Completed')).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('renders the timeline with a row per node, ordered by executionOrder', () => {
|
|
56
|
+
render(<WorkflowRunViewer execution={makeExecution()} />);
|
|
57
|
+
const rows = screen.getAllByRole('listitem');
|
|
58
|
+
expect(rows).toHaveLength(2);
|
|
59
|
+
expect(rows[0]).toHaveTextContent('Fetch data');
|
|
60
|
+
expect(rows[1]).toHaveTextContent('Transform');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('shows selected-node details when a timeline row is selected', () => {
|
|
64
|
+
render(<WorkflowRunViewer execution={makeExecution()} />);
|
|
65
|
+
fireEvent.click(screen.getByText('Fetch data'));
|
|
66
|
+
// detail panel shows the selected node name as a heading + pretty JSON
|
|
67
|
+
expect(
|
|
68
|
+
screen.getByRole('heading', { name: 'Fetch data' })
|
|
69
|
+
).toBeInTheDocument();
|
|
70
|
+
expect(screen.getByText(/"foo": "out"/)).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('updates the selected-node details when a new snapshot is passed', () => {
|
|
74
|
+
const { rerender } = render(<WorkflowRunViewer execution={makeExecution()} />);
|
|
75
|
+
fireEvent.click(screen.getByText('Transform'));
|
|
76
|
+
// node b selected; detail panel headed by its name
|
|
77
|
+
expect(
|
|
78
|
+
screen.getByRole('heading', { name: 'Transform' })
|
|
79
|
+
).toBeInTheDocument();
|
|
80
|
+
|
|
81
|
+
const next = makeExecution({ status: 'completed' });
|
|
82
|
+
next.nodes.b = {
|
|
83
|
+
...next.nodes.b,
|
|
84
|
+
status: 'success',
|
|
85
|
+
executionTimeMs: 250,
|
|
86
|
+
outputData: { done: true },
|
|
87
|
+
};
|
|
88
|
+
rerender(<WorkflowRunViewer execution={next} />);
|
|
89
|
+
// detail panel reflects the new snapshot for the still-selected node b
|
|
90
|
+
expect(screen.getByText(/"done": true/)).toBeInTheDocument();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('renders an empty state when there are no node executions', () => {
|
|
94
|
+
render(
|
|
95
|
+
<WorkflowRunViewer
|
|
96
|
+
execution={{ executionId: 'x', status: 'pending', nodes: {} }}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
|
|
100
|
+
expect(screen.getByText('Pending')).toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('ExecNodeDetails', () => {
|
|
105
|
+
const node: NodeExecView = {
|
|
106
|
+
nodeId: 'a',
|
|
107
|
+
name: 'Fetch data',
|
|
108
|
+
type: 'trigger.event',
|
|
109
|
+
status: 'error',
|
|
110
|
+
executionOrder: 0,
|
|
111
|
+
error: 'boom',
|
|
112
|
+
inputData: { a: 1 },
|
|
113
|
+
outputData: { b: 2 },
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
it('renders node name, type and status', () => {
|
|
117
|
+
render(<ExecNodeDetails node={node} />);
|
|
118
|
+
expect(screen.getByText('Fetch data')).toBeInTheDocument();
|
|
119
|
+
expect(screen.getByText('trigger.event')).toBeInTheDocument();
|
|
120
|
+
expect(screen.getByText('Error')).toBeInTheDocument();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('pretty-prints input and output JSON', () => {
|
|
124
|
+
render(<ExecNodeDetails node={node} />);
|
|
125
|
+
expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
|
|
126
|
+
expect(screen.getByText(/"b": 2/)).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('renders error message when present', () => {
|
|
130
|
+
render(<ExecNodeDetails node={node} />);
|
|
131
|
+
expect(screen.getByText('boom')).toBeInTheDocument();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('renders an empty state when no node is selected', () => {
|
|
135
|
+
render(<ExecNodeDetails node={null} />);
|
|
136
|
+
expect(screen.getByText(/select a node/i)).toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { autoLayout } from '../layout/auto-layout';
|
|
2
|
+
import { graphToFlow } from '../serialization';
|
|
3
|
+
import type { WorkflowGraph } from '../types';
|
|
4
|
+
|
|
5
|
+
function noOverlap(
|
|
6
|
+
nodes: { id: string; position: { x: number; y: number }; width?: number; height?: number }[]
|
|
7
|
+
): boolean {
|
|
8
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
9
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
10
|
+
const a = nodes[i];
|
|
11
|
+
const b = nodes[j];
|
|
12
|
+
const aw = a.width ?? 180;
|
|
13
|
+
const ah = a.height ?? 60;
|
|
14
|
+
const bw = b.width ?? 180;
|
|
15
|
+
const bh = b.height ?? 60;
|
|
16
|
+
const overlapX = a.position.x < b.position.x + bw && a.position.x + aw > b.position.x;
|
|
17
|
+
const overlapY = a.position.y < b.position.y + bh && a.position.y + ah > b.position.y;
|
|
18
|
+
if (overlapX && overlapY) return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const chainGraph: WorkflowGraph = {
|
|
25
|
+
nodes: [
|
|
26
|
+
{ id: 'a', name: 'A', type: 't' },
|
|
27
|
+
{ id: 'b', name: 'B', type: 't' },
|
|
28
|
+
{ id: 'c', name: 'C', type: 't' },
|
|
29
|
+
],
|
|
30
|
+
edges: [
|
|
31
|
+
{ source: 'a', target: 'b' },
|
|
32
|
+
{ source: 'b', target: 'c' },
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('auto-layout', () => {
|
|
37
|
+
it('assigns non-overlapping positions (LR)', () => {
|
|
38
|
+
const { nodes, edges } = graphToFlow(chainGraph);
|
|
39
|
+
const laid = autoLayout(nodes, edges, { direction: 'LR' });
|
|
40
|
+
expect(laid.nodes).toHaveLength(3);
|
|
41
|
+
expect(noOverlap(laid.nodes)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('LR lays out left-to-right (x increases along the chain)', () => {
|
|
45
|
+
const { nodes, edges } = graphToFlow(chainGraph);
|
|
46
|
+
const laid = autoLayout(nodes, edges, { direction: 'LR' });
|
|
47
|
+
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
|
|
48
|
+
expect(byId.b.position.x).toBeGreaterThan(byId.a.position.x);
|
|
49
|
+
expect(byId.c.position.x).toBeGreaterThan(byId.b.position.x);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('TB lays out top-to-bottom (y increases along the chain)', () => {
|
|
53
|
+
const { nodes, edges } = graphToFlow(chainGraph);
|
|
54
|
+
const laid = autoLayout(nodes, edges, { direction: 'TB' });
|
|
55
|
+
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
|
|
56
|
+
expect(byId.b.position.y).toBeGreaterThan(byId.a.position.y);
|
|
57
|
+
expect(byId.c.position.y).toBeGreaterThan(byId.b.position.y);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('preserves explicit positions when preservePositions is set', () => {
|
|
61
|
+
const graph: WorkflowGraph = {
|
|
62
|
+
nodes: [
|
|
63
|
+
{ id: 'a', name: 'A', type: 't', position: { x: 500, y: 500 } },
|
|
64
|
+
{ id: 'b', name: 'B', type: 't' },
|
|
65
|
+
],
|
|
66
|
+
edges: [{ source: 'a', target: 'b' }],
|
|
67
|
+
};
|
|
68
|
+
const { nodes, edges } = graphToFlow(graph);
|
|
69
|
+
const laid = autoLayout(nodes, edges, { direction: 'LR', preservePositions: true });
|
|
70
|
+
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
|
|
71
|
+
expect(byId.a.position).toEqual({ x: 500, y: 500 });
|
|
72
|
+
// b still gets a computed position
|
|
73
|
+
expect(byId.b.position).toBeDefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('places an edgeless / orphan node (still gets a position, no overlap)', () => {
|
|
77
|
+
const graph: WorkflowGraph = {
|
|
78
|
+
nodes: [
|
|
79
|
+
{ id: 'a', name: 'A', type: 't' },
|
|
80
|
+
{ id: 'b', name: 'B', type: 't' },
|
|
81
|
+
{ id: 'orphan', name: 'O', type: 't' },
|
|
82
|
+
],
|
|
83
|
+
edges: [{ source: 'a', target: 'b' }],
|
|
84
|
+
};
|
|
85
|
+
const { nodes, edges } = graphToFlow(graph);
|
|
86
|
+
const laid = autoLayout(nodes, edges, { direction: 'LR' });
|
|
87
|
+
const orphan = laid.nodes.find((n) => n.id === 'orphan');
|
|
88
|
+
expect(orphan).toBeDefined();
|
|
89
|
+
expect(orphan!.position).toBeDefined();
|
|
90
|
+
expect(Number.isFinite(orphan!.position.x)).toBe(true);
|
|
91
|
+
expect(Number.isFinite(orphan!.position.y)).toBe(true);
|
|
92
|
+
expect(noOverlap(laid.nodes)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('handles an empty node set', () => {
|
|
96
|
+
const laid = autoLayout([], [], { direction: 'TB' });
|
|
97
|
+
expect(laid.nodes).toEqual([]);
|
|
98
|
+
expect(laid.edges).toEqual([]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('defaults to TB when no direction is given', () => {
|
|
102
|
+
const { nodes, edges } = graphToFlow(chainGraph);
|
|
103
|
+
const laid = autoLayout(nodes, edges);
|
|
104
|
+
const byId = Object.fromEntries(laid.nodes.map((n) => [n.id, n]));
|
|
105
|
+
expect(byId.c.position.y).toBeGreaterThan(byId.a.position.y);
|
|
106
|
+
});
|
|
107
|
+
});
|