@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,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the team-management components.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the @startsimpli/api shapes, but redeclared here so the UI
|
|
5
|
+
* package doesn't pull @startsimpli/api as a peer. Each component accepts
|
|
6
|
+
* the data it actually renders — apps pass through whatever @startsimpli/api
|
|
7
|
+
* gave them. startsim-o7s.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type * as React from 'react'
|
|
11
|
+
|
|
12
|
+
export type TeamRole = 'owner' | 'admin' | 'member' | 'viewer'
|
|
13
|
+
|
|
14
|
+
export interface TeamLite {
|
|
15
|
+
id: string
|
|
16
|
+
slug: string
|
|
17
|
+
name: string
|
|
18
|
+
companyId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CompanyLite {
|
|
22
|
+
id: string
|
|
23
|
+
slug: string
|
|
24
|
+
name: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** User shape rendered next to each row. Optional fields stay optional. */
|
|
28
|
+
export interface MemberUserLite {
|
|
29
|
+
id: string
|
|
30
|
+
email: string
|
|
31
|
+
firstName?: string
|
|
32
|
+
lastName?: string
|
|
33
|
+
fullName?: string
|
|
34
|
+
/** Optional URL to an avatar image. */
|
|
35
|
+
avatarUrl?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MemberRow {
|
|
39
|
+
id: string
|
|
40
|
+
userId: string
|
|
41
|
+
teamId: string
|
|
42
|
+
role: TeamRole
|
|
43
|
+
joinedAt: string
|
|
44
|
+
/** Optionally embedded; when absent, the email + role-only render path is used. */
|
|
45
|
+
user?: MemberUserLite
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface InvitationLite {
|
|
49
|
+
id: string
|
|
50
|
+
email: string
|
|
51
|
+
teamId: string
|
|
52
|
+
role: TeamRole
|
|
53
|
+
/** Only present on the create-response; otherwise the backend redacts. */
|
|
54
|
+
token?: string
|
|
55
|
+
expiresAt: string
|
|
56
|
+
acceptedAt?: string | null
|
|
57
|
+
revokedAt?: string | null
|
|
58
|
+
isExpired: boolean
|
|
59
|
+
isAccepted: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DomainVerificationMethod = 'dns_txt' | 'email_attestation'
|
|
63
|
+
|
|
64
|
+
export interface DomainClaimLite {
|
|
65
|
+
id: string
|
|
66
|
+
companyId: string
|
|
67
|
+
domain: string
|
|
68
|
+
verified: boolean
|
|
69
|
+
verificationMethod?: DomainVerificationMethod | null
|
|
70
|
+
verificationToken?: string | null
|
|
71
|
+
verifiedAt?: string | null
|
|
72
|
+
createdAt: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Helper: render a human-friendly label for a user — fullName wins, then
|
|
77
|
+
* firstName + lastName, then email.
|
|
78
|
+
*/
|
|
79
|
+
export function userDisplayName(user: MemberUserLite | undefined): string {
|
|
80
|
+
if (!user) return ''
|
|
81
|
+
if (user.fullName) return user.fullName
|
|
82
|
+
const fl = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
|
83
|
+
return fl || user.email
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Helper: render initials for the avatar fallback. */
|
|
87
|
+
export function userInitials(user: MemberUserLite | undefined): string {
|
|
88
|
+
if (!user) return '?'
|
|
89
|
+
const name = userDisplayName(user)
|
|
90
|
+
const parts = name.split(/\s+/).filter(Boolean)
|
|
91
|
+
if (parts.length === 0) return '?'
|
|
92
|
+
if (parts.length === 1) return parts[0][0].toUpperCase()
|
|
93
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Common React-prop alias used by every team component. */
|
|
97
|
+
export type ChildrenProps = { children?: React.ReactNode }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { StatusBadge } from '../badge/StatusBadge';
|
|
6
|
+
import { EmptyState } from '../states/EmptyState';
|
|
7
|
+
import type { NodeExecView } from './types';
|
|
8
|
+
import { NODE_STATUS_BADGE, formatDuration } from './exec-status';
|
|
9
|
+
|
|
10
|
+
export interface ExecNodeDetailsProps {
|
|
11
|
+
/** The selected node execution, or null when nothing is selected. */
|
|
12
|
+
node: NodeExecView | null | undefined;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function JsonBlock({
|
|
17
|
+
label,
|
|
18
|
+
value,
|
|
19
|
+
}: {
|
|
20
|
+
label: string;
|
|
21
|
+
value: Record<string, unknown> | null | undefined;
|
|
22
|
+
}) {
|
|
23
|
+
const isEmpty = value == null || Object.keys(value).length === 0;
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex min-h-0 flex-col">
|
|
26
|
+
<h4 className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
27
|
+
{label}
|
|
28
|
+
</h4>
|
|
29
|
+
{isEmpty ? (
|
|
30
|
+
<p className="text-xs italic text-muted-foreground">No {label.toLowerCase()}.</p>
|
|
31
|
+
) : (
|
|
32
|
+
<pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
|
|
33
|
+
{JSON.stringify(value, null, 2)}
|
|
34
|
+
</pre>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read-only detail panel for a single node execution: name, type, status
|
|
42
|
+
* badge, duration, error, and pretty-printed input/output JSON. Purpose-built
|
|
43
|
+
* for the execution monitor — deliberately NOT the editor's NodeInspector.
|
|
44
|
+
*/
|
|
45
|
+
export function ExecNodeDetails({ node, className }: ExecNodeDetailsProps) {
|
|
46
|
+
if (!node) {
|
|
47
|
+
return (
|
|
48
|
+
<EmptyState
|
|
49
|
+
title="No node selected"
|
|
50
|
+
description="Select a node in the timeline to inspect its input and output."
|
|
51
|
+
className={className}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const duration = formatDuration(node.executionTimeMs);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={cn('flex h-full min-h-0 flex-col gap-4 overflow-auto p-4', className)}>
|
|
60
|
+
<header className="flex flex-col gap-2">
|
|
61
|
+
<div className="flex items-center gap-2">
|
|
62
|
+
<h3 className="truncate text-base font-semibold text-foreground">
|
|
63
|
+
{node.name ?? node.nodeId}
|
|
64
|
+
</h3>
|
|
65
|
+
<StatusBadge status={node.status} config={NODE_STATUS_BADGE} size="sm" />
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
68
|
+
{node.type && <span>{node.type}</span>}
|
|
69
|
+
{duration && <span className="tabular-nums">{duration}</span>}
|
|
70
|
+
</div>
|
|
71
|
+
</header>
|
|
72
|
+
|
|
73
|
+
{node.status === 'error' && node.error && (
|
|
74
|
+
<p className="break-words rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
|
75
|
+
{node.error}
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<JsonBlock label="Input" value={node.inputData} />
|
|
80
|
+
<JsonBlock label="Output" value={node.outputData} />
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { StatusBadge } from '../badge/StatusBadge';
|
|
6
|
+
import { EmptyState } from '../states/EmptyState';
|
|
7
|
+
import type { NodeExecView } from './types';
|
|
8
|
+
import { NODE_STATUS_BADGE, formatDuration, isLiveStatus } from './exec-status';
|
|
9
|
+
|
|
10
|
+
export interface ExecutionTimelineProps {
|
|
11
|
+
/** Per-node execution overlays; rendered sorted by `executionOrder`. */
|
|
12
|
+
nodeExecutions: NodeExecView[];
|
|
13
|
+
/** Currently selected node id (row highlighted as active). */
|
|
14
|
+
activeNodeId?: string;
|
|
15
|
+
/** Called with a node id when its row is activated. */
|
|
16
|
+
onSelectNode?: (nodeId: string) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function byExecutionOrder(a: NodeExecView, b: NodeExecView): number {
|
|
21
|
+
const ao = a.executionOrder ?? 0;
|
|
22
|
+
const bo = b.executionOrder ?? 0;
|
|
23
|
+
return ao - bo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Vertical execution timeline: one row per node execution (ordered by
|
|
28
|
+
* `executionOrder`), each showing the node name, a status badge, the run
|
|
29
|
+
* duration, and inline error text for failed nodes. Running nodes show a
|
|
30
|
+
* pulsing live indicator; terminal nodes render statically.
|
|
31
|
+
*
|
|
32
|
+
* Read-only and app-agnostic — selection is surfaced via `onSelectNode`.
|
|
33
|
+
*/
|
|
34
|
+
export function ExecutionTimeline({
|
|
35
|
+
nodeExecutions,
|
|
36
|
+
activeNodeId,
|
|
37
|
+
onSelectNode,
|
|
38
|
+
className,
|
|
39
|
+
}: ExecutionTimelineProps) {
|
|
40
|
+
const ordered = React.useMemo(
|
|
41
|
+
() => [...nodeExecutions].sort(byExecutionOrder),
|
|
42
|
+
[nodeExecutions]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (ordered.length === 0) {
|
|
46
|
+
return (
|
|
47
|
+
<EmptyState
|
|
48
|
+
title="No node executions"
|
|
49
|
+
description="This run has not produced any node activity yet."
|
|
50
|
+
className={className}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<ol className={cn('flex flex-col', className)} aria-label="Execution timeline">
|
|
57
|
+
{ordered.map((node) => {
|
|
58
|
+
const live = isLiveStatus(node.status);
|
|
59
|
+
const isActive = activeNodeId != null && node.nodeId === activeNodeId;
|
|
60
|
+
const duration = formatDuration(node.executionTimeMs);
|
|
61
|
+
const selectable = onSelectNode != null;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<li
|
|
65
|
+
key={node.nodeId}
|
|
66
|
+
aria-current={isActive ? 'true' : undefined}
|
|
67
|
+
data-node-id={node.nodeId}
|
|
68
|
+
data-status={node.status}
|
|
69
|
+
className={cn(
|
|
70
|
+
'group flex items-start gap-3 border-b border-border px-3 py-2.5 text-sm last:border-b-0',
|
|
71
|
+
selectable && 'cursor-pointer hover:bg-accent/50',
|
|
72
|
+
isActive && 'bg-accent'
|
|
73
|
+
)}
|
|
74
|
+
onClick={selectable ? () => onSelectNode!(node.nodeId) : undefined}
|
|
75
|
+
onKeyDown={
|
|
76
|
+
selectable
|
|
77
|
+
? (e) => {
|
|
78
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
onSelectNode!(node.nodeId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
: undefined
|
|
84
|
+
}
|
|
85
|
+
tabIndex={selectable ? 0 : undefined}
|
|
86
|
+
>
|
|
87
|
+
{/* Status rail dot */}
|
|
88
|
+
<span className="relative mt-1 flex h-2.5 w-2.5 shrink-0 items-center justify-center">
|
|
89
|
+
{live ? (
|
|
90
|
+
<>
|
|
91
|
+
<span
|
|
92
|
+
data-live="true"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
|
95
|
+
/>
|
|
96
|
+
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
|
|
97
|
+
</>
|
|
98
|
+
) : (
|
|
99
|
+
<span
|
|
100
|
+
className={cn(
|
|
101
|
+
'inline-flex h-2.5 w-2.5 rounded-full',
|
|
102
|
+
node.status === 'success' && 'bg-green-500',
|
|
103
|
+
node.status === 'error' && 'bg-red-500',
|
|
104
|
+
node.status === 'skipped' && 'bg-gray-300',
|
|
105
|
+
node.status === 'pending' && 'bg-amber-400',
|
|
106
|
+
node.status === 'idle' && 'bg-gray-300'
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
</span>
|
|
111
|
+
|
|
112
|
+
<div className="min-w-0 flex-1">
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<span className="truncate font-medium text-foreground">
|
|
115
|
+
{node.name ?? node.nodeId}
|
|
116
|
+
</span>
|
|
117
|
+
{duration && (
|
|
118
|
+
<span className="ml-auto shrink-0 tabular-nums text-xs text-muted-foreground">
|
|
119
|
+
{duration}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
<div className="mt-1 flex items-center gap-2">
|
|
124
|
+
<StatusBadge
|
|
125
|
+
status={node.status}
|
|
126
|
+
config={NODE_STATUS_BADGE}
|
|
127
|
+
size="sm"
|
|
128
|
+
/>
|
|
129
|
+
{node.type && (
|
|
130
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
131
|
+
{node.type}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
{node.status === 'error' && node.error && (
|
|
136
|
+
<p className="mt-1.5 break-words text-xs text-red-600">
|
|
137
|
+
{node.error}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ol>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
|
6
|
+
import { StatusBadge } from '../badge/StatusBadge';
|
|
7
|
+
import { EmptyState } from '../states/EmptyState';
|
|
8
|
+
import { NODE_STATUS_BADGE } from './exec-status';
|
|
9
|
+
import type { JsonSchema, NodeExecView, NodeTypeDef, WfNode } from './types';
|
|
10
|
+
|
|
11
|
+
export interface NodeInspectorProps {
|
|
12
|
+
/** The selected node, or null when nothing is selected. */
|
|
13
|
+
node: WfNode | null | undefined;
|
|
14
|
+
/** Node-type def supplying the parameter schema. */
|
|
15
|
+
nodeType?: NodeTypeDef;
|
|
16
|
+
/** Emitted with the node id + full next parameter object on any change. */
|
|
17
|
+
onParametersChange?: (nodeId: string, parameters: Record<string, unknown>) => void;
|
|
18
|
+
/** When present, adds read-only Input/Output exec tabs. */
|
|
19
|
+
execution?: NodeExecView;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function fieldId(nodeId: string, key: string): string {
|
|
24
|
+
return `nodeparam-${nodeId}-${key}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function SchemaField({
|
|
28
|
+
nodeId,
|
|
29
|
+
name,
|
|
30
|
+
schema,
|
|
31
|
+
value,
|
|
32
|
+
onChange,
|
|
33
|
+
}: {
|
|
34
|
+
nodeId: string;
|
|
35
|
+
name: string;
|
|
36
|
+
schema: JsonSchema;
|
|
37
|
+
value: unknown;
|
|
38
|
+
onChange: (next: unknown) => void;
|
|
39
|
+
}) {
|
|
40
|
+
const id = fieldId(nodeId, name);
|
|
41
|
+
const label = schema.title ?? name;
|
|
42
|
+
|
|
43
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
44
|
+
return (
|
|
45
|
+
<div className="flex flex-col gap-1">
|
|
46
|
+
<label htmlFor={id} className="text-xs font-medium text-foreground">
|
|
47
|
+
{label}
|
|
48
|
+
</label>
|
|
49
|
+
<select
|
|
50
|
+
id={id}
|
|
51
|
+
value={String(value ?? '')}
|
|
52
|
+
onChange={(e) => onChange(e.target.value)}
|
|
53
|
+
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
54
|
+
>
|
|
55
|
+
{schema.enum.map((opt) => (
|
|
56
|
+
<option key={String(opt)} value={String(opt)}>
|
|
57
|
+
{String(opt)}
|
|
58
|
+
</option>
|
|
59
|
+
))}
|
|
60
|
+
</select>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (schema.type === 'boolean') {
|
|
66
|
+
return (
|
|
67
|
+
<div className="flex items-center gap-2">
|
|
68
|
+
<input
|
|
69
|
+
id={id}
|
|
70
|
+
type="checkbox"
|
|
71
|
+
checked={Boolean(value)}
|
|
72
|
+
onChange={(e) => onChange(e.target.checked)}
|
|
73
|
+
className="h-4 w-4 rounded border-border"
|
|
74
|
+
/>
|
|
75
|
+
<label htmlFor={id} className="text-xs font-medium text-foreground">
|
|
76
|
+
{label}
|
|
77
|
+
</label>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (schema.type === 'number' || schema.type === 'integer') {
|
|
83
|
+
return (
|
|
84
|
+
<div className="flex flex-col gap-1">
|
|
85
|
+
<label htmlFor={id} className="text-xs font-medium text-foreground">
|
|
86
|
+
{label}
|
|
87
|
+
</label>
|
|
88
|
+
<input
|
|
89
|
+
id={id}
|
|
90
|
+
type="number"
|
|
91
|
+
value={value === undefined || value === null ? '' : Number(value)}
|
|
92
|
+
onChange={(e) =>
|
|
93
|
+
onChange(e.target.value === '' ? undefined : Number(e.target.value))
|
|
94
|
+
}
|
|
95
|
+
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
96
|
+
/>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// default: string
|
|
102
|
+
return (
|
|
103
|
+
<div className="flex flex-col gap-1">
|
|
104
|
+
<label htmlFor={id} className="text-xs font-medium text-foreground">
|
|
105
|
+
{label}
|
|
106
|
+
</label>
|
|
107
|
+
<input
|
|
108
|
+
id={id}
|
|
109
|
+
type="text"
|
|
110
|
+
value={value === undefined || value === null ? '' : String(value)}
|
|
111
|
+
onChange={(e) => onChange(e.target.value)}
|
|
112
|
+
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
113
|
+
/>
|
|
114
|
+
{schema.description && (
|
|
115
|
+
<p className="text-[11px] text-muted-foreground">{schema.description}</p>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function JsonBlock({ value }: { value: Record<string, unknown> | null | undefined }) {
|
|
122
|
+
const isEmpty = value == null || Object.keys(value).length === 0;
|
|
123
|
+
if (isEmpty) {
|
|
124
|
+
return <p className="text-xs italic text-muted-foreground">No data.</p>;
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
<pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
|
|
128
|
+
{JSON.stringify(value, null, 2)}
|
|
129
|
+
</pre>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function ParamForm({
|
|
134
|
+
node,
|
|
135
|
+
schema,
|
|
136
|
+
onParametersChange,
|
|
137
|
+
}: {
|
|
138
|
+
node: WfNode;
|
|
139
|
+
schema: JsonSchema | undefined;
|
|
140
|
+
onParametersChange?: NodeInspectorProps['onParametersChange'];
|
|
141
|
+
}) {
|
|
142
|
+
const params = (node.parameters ?? {}) as Record<string, unknown>;
|
|
143
|
+
const properties = schema?.properties ?? {};
|
|
144
|
+
const keys = Object.keys(properties);
|
|
145
|
+
|
|
146
|
+
const handleChange = (key: string, next: unknown) => {
|
|
147
|
+
const updated = { ...params, [key]: next };
|
|
148
|
+
onParametersChange?.(node.id, updated);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (keys.length === 0) {
|
|
152
|
+
return (
|
|
153
|
+
<p className="text-xs italic text-muted-foreground">
|
|
154
|
+
This node type has no configurable parameters.
|
|
155
|
+
</p>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<div className="flex flex-col gap-3">
|
|
161
|
+
{keys.map((key) => (
|
|
162
|
+
<SchemaField
|
|
163
|
+
key={key}
|
|
164
|
+
nodeId={node.id}
|
|
165
|
+
name={key}
|
|
166
|
+
schema={properties[key]}
|
|
167
|
+
value={params[key]}
|
|
168
|
+
onChange={(next) => handleChange(key, next)}
|
|
169
|
+
/>
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Editor inspector panel for the selected node. Renders a JSON-Schema-driven
|
|
177
|
+
* read/write form from the node-type's `parameterSchema` (string/number/
|
|
178
|
+
* boolean/enum fields), emitting the full next `parameters` object via
|
|
179
|
+
* `onParametersChange`. When an {@link NodeExecView} is supplied, adds
|
|
180
|
+
* read-only Input/Output tabs alongside the parameters tab.
|
|
181
|
+
*/
|
|
182
|
+
export function NodeInspector({
|
|
183
|
+
node,
|
|
184
|
+
nodeType,
|
|
185
|
+
onParametersChange,
|
|
186
|
+
execution,
|
|
187
|
+
className,
|
|
188
|
+
}: NodeInspectorProps) {
|
|
189
|
+
if (!node) {
|
|
190
|
+
return (
|
|
191
|
+
<EmptyState
|
|
192
|
+
title="No node selected"
|
|
193
|
+
description="Select a node on the canvas to edit its parameters."
|
|
194
|
+
className={className}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const header = (
|
|
200
|
+
<header className="flex flex-col gap-1 border-b border-border px-4 py-3">
|
|
201
|
+
<div className="flex items-center gap-2">
|
|
202
|
+
<h3 className="truncate text-sm font-semibold text-foreground">
|
|
203
|
+
{node.name}
|
|
204
|
+
</h3>
|
|
205
|
+
{execution && (
|
|
206
|
+
<StatusBadge
|
|
207
|
+
status={execution.status}
|
|
208
|
+
config={NODE_STATUS_BADGE}
|
|
209
|
+
size="sm"
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
<span className="text-xs text-muted-foreground">{node.type}</span>
|
|
214
|
+
</header>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
const paramForm = (
|
|
218
|
+
<div className="overflow-auto p-4">
|
|
219
|
+
<ParamForm
|
|
220
|
+
node={node}
|
|
221
|
+
schema={nodeType?.parameterSchema}
|
|
222
|
+
onParametersChange={onParametersChange}
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!execution) {
|
|
228
|
+
return (
|
|
229
|
+
<div className={cn('flex h-full min-h-0 flex-col', className)}>
|
|
230
|
+
{header}
|
|
231
|
+
{paramForm}
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<div className={cn('flex h-full min-h-0 flex-col', className)}>
|
|
238
|
+
{header}
|
|
239
|
+
<Tabs defaultValue="params" className="flex min-h-0 flex-1 flex-col">
|
|
240
|
+
<TabsList className="mx-4 mt-3 self-start">
|
|
241
|
+
<TabsTrigger value="params">Parameters</TabsTrigger>
|
|
242
|
+
<TabsTrigger value="input">Input</TabsTrigger>
|
|
243
|
+
<TabsTrigger value="output">Output</TabsTrigger>
|
|
244
|
+
</TabsList>
|
|
245
|
+
<TabsContent value="params" className="min-h-0 flex-1">
|
|
246
|
+
{paramForm}
|
|
247
|
+
</TabsContent>
|
|
248
|
+
<TabsContent value="input" className="min-h-0 flex-1 overflow-auto p-4">
|
|
249
|
+
<JsonBlock value={execution.inputData} />
|
|
250
|
+
</TabsContent>
|
|
251
|
+
<TabsContent value="output" className="min-h-0 flex-1 overflow-auto p-4">
|
|
252
|
+
<JsonBlock value={execution.outputData} />
|
|
253
|
+
</TabsContent>
|
|
254
|
+
</Tabs>
|
|
255
|
+
</div>
|
|
256
|
+
);
|
|
257
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { getCategoryToken } from './theme/categories';
|
|
6
|
+
import { getCategoryIcon } from './node-icons';
|
|
7
|
+
import type { NodeTypeDef } from './types';
|
|
8
|
+
|
|
9
|
+
/** MIME-ish key used to carry a node-type slug across HTML5 drag-and-drop. */
|
|
10
|
+
export const NODE_DND_MIME = 'application/x-workflow-node';
|
|
11
|
+
|
|
12
|
+
export interface NodePaletteProps {
|
|
13
|
+
/** The node-type catalog to list. */
|
|
14
|
+
nodeTypes: NodeTypeDef[];
|
|
15
|
+
/** Optional click handler (e.g. add node at canvas center). */
|
|
16
|
+
onAddNode?: (slug: string) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function matches(nt: NodeTypeDef, query: string): boolean {
|
|
21
|
+
if (!query) return true;
|
|
22
|
+
const q = query.toLowerCase();
|
|
23
|
+
return (
|
|
24
|
+
nt.name.toLowerCase().includes(q) ||
|
|
25
|
+
nt.slug.toLowerCase().includes(q) ||
|
|
26
|
+
nt.category.toLowerCase().includes(q) ||
|
|
27
|
+
(nt.description?.toLowerCase().includes(q) ?? false)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function groupByCategory(
|
|
32
|
+
nodeTypes: NodeTypeDef[]
|
|
33
|
+
): { category: string; items: NodeTypeDef[] }[] {
|
|
34
|
+
const groups = new Map<string, NodeTypeDef[]>();
|
|
35
|
+
for (const nt of nodeTypes) {
|
|
36
|
+
const list = groups.get(nt.category) ?? [];
|
|
37
|
+
list.push(nt);
|
|
38
|
+
groups.set(nt.category, list);
|
|
39
|
+
}
|
|
40
|
+
return [...groups.entries()].map(([category, items]) => ({ category, items }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Draggable palette of {@link NodeTypeDef}s, grouped by category with a search
|
|
45
|
+
* filter. Each item is HTML5-draggable: dragging sets the node-type slug on
|
|
46
|
+
* `dataTransfer` under {@link NODE_DND_MIME} for the canvas to read on drop.
|
|
47
|
+
*/
|
|
48
|
+
export function NodePalette({ nodeTypes, onAddNode, className }: NodePaletteProps) {
|
|
49
|
+
const [query, setQuery] = React.useState('');
|
|
50
|
+
|
|
51
|
+
const groups = React.useMemo(() => {
|
|
52
|
+
const filtered = nodeTypes.filter((nt) => matches(nt, query));
|
|
53
|
+
return groupByCategory(filtered);
|
|
54
|
+
}, [nodeTypes, query]);
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className={cn('flex h-full min-h-0 flex-col', className)}>
|
|
58
|
+
<div className="shrink-0 border-b border-border p-2">
|
|
59
|
+
<input
|
|
60
|
+
type="search"
|
|
61
|
+
role="searchbox"
|
|
62
|
+
aria-label="Filter node types"
|
|
63
|
+
placeholder="Search nodes…"
|
|
64
|
+
value={query}
|
|
65
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
66
|
+
className="w-full rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="min-h-0 flex-1 overflow-auto p-2">
|
|
71
|
+
{groups.length === 0 ? (
|
|
72
|
+
<p className="px-1 py-4 text-center text-xs text-muted-foreground">
|
|
73
|
+
No matching nodes.
|
|
74
|
+
</p>
|
|
75
|
+
) : (
|
|
76
|
+
groups.map(({ category, items }) => {
|
|
77
|
+
const token = getCategoryToken(category);
|
|
78
|
+
return (
|
|
79
|
+
<div key={category} className="mb-3">
|
|
80
|
+
<h4 className="mb-1 px-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
|
|
81
|
+
{token.label}
|
|
82
|
+
</h4>
|
|
83
|
+
<ul className="flex flex-col gap-1">
|
|
84
|
+
{items.map((nt) => {
|
|
85
|
+
const Icon = getCategoryIcon(token.icon);
|
|
86
|
+
return (
|
|
87
|
+
<li key={nt.slug}>
|
|
88
|
+
<div
|
|
89
|
+
draggable
|
|
90
|
+
onDragStart={(e) => {
|
|
91
|
+
e.dataTransfer.setData(NODE_DND_MIME, nt.slug);
|
|
92
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
93
|
+
}}
|
|
94
|
+
onClick={() => onAddNode?.(nt.slug)}
|
|
95
|
+
className="flex cursor-grab items-center gap-2 rounded-md border border-border bg-card px-2 py-1.5 text-sm transition-colors hover:bg-accent active:cursor-grabbing"
|
|
96
|
+
title={nt.description ?? nt.name}
|
|
97
|
+
>
|
|
98
|
+
<span
|
|
99
|
+
className="flex h-6 w-6 shrink-0 items-center justify-center rounded"
|
|
100
|
+
style={{ background: token.background, color: token.color }}
|
|
101
|
+
>
|
|
102
|
+
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
103
|
+
</span>
|
|
104
|
+
<span className="min-w-0 flex-1 truncate text-foreground">
|
|
105
|
+
{nt.name}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
</li>
|
|
109
|
+
);
|
|
110
|
+
})}
|
|
111
|
+
</ul>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
})
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|