@calliopelabs/ui 0.12.0 → 0.13.0
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 +18 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/provider/messages.d.ts +17 -1
- package/dist/provider/messages.js +20 -0
- package/dist/workflow/canvas/WorkflowCanvas.js +41 -1
- package/dist/workflow/definition.d.ts +20 -0
- package/dist/workflow/definition.js +44 -0
- package/dist/workflow/editor/WorkflowInspector.d.ts +21 -3
- package/dist/workflow/editor/WorkflowInspector.js +13 -5
- package/dist/workflow/editor/WorkflowWorkspace.d.ts +28 -3
- package/dist/workflow/editor/WorkflowWorkspace.js +75 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -373,6 +373,24 @@ const registry: WorkflowRegistry = {
|
|
|
373
373
|
|
|
374
374
|
It marks unsaved edits and a draft that differs from live (`documentStatus`). A newer saved revision is taken when there is nothing to lose; when it moves under unsaved edits, it stops and asks: compare, use theirs, or keep mine. Publishing shows the draft against live (`WorkflowDiff`) and is held while anything blocks. A rejected promise's message is shown and the edits stay. Persistence and authority stay with the host: it never saves, publishes or runs anything itself. Below a 64rem container the palette and the side panel open as drawers.
|
|
375
375
|
|
|
376
|
+
A host can reach further into the editor. Each is opt-in; without them the editor is as above.
|
|
377
|
+
|
|
378
|
+
- **Deep links.** `defaultSelection` opens the editor on a flow and a node (`{ type: "node", flow, id }`); `selection` with `onSelectionChange` holds it in the host. A selection in another flow opens that flow, and the canvas brings the node into view.
|
|
379
|
+
- **Flows.** `onFlowCreate` offers New flow and `onFlowRename` offers Rename flow for the open one. The dialog checks the name as it is typed against `WORKFLOW_FLOW_NAME` (a lowercase slug of up to 32 characters), against the other flows and against `WORKFLOW_MAX_FLOWS`, then hands the host the next definition to apply. `renameFlow` renames the key, `main` and every sub-flow node that runs it; anything else the host keeps that names flows is the host's to follow. `flowNameProblem`, `addFlow` and `renameFlow` are exported for hosts that edit flows themselves.
|
|
380
|
+
- **Host fields.** `renderNodeFields` adds the host's own fields to the inspector after a node's kind fields, with the node, its flow and an `onChange` that patches it. Switch on `node.kind` and return null for none.
|
|
381
|
+
|
|
382
|
+
```tsx
|
|
383
|
+
<WorkflowWorkspace
|
|
384
|
+
{...props}
|
|
385
|
+
defaultSelection={at ? { type: "node", flow: at.flow, id: at.node } : null}
|
|
386
|
+
onFlowCreate={(next) => setDraft(next)}
|
|
387
|
+
onFlowRename={(next, from, to) => setDraft(next) /* and follow `from` to `to` in what you keep */}
|
|
388
|
+
renderNodeFields={({ node, readOnly }) =>
|
|
389
|
+
node.kind === "step" ? <RunnerPicker step={node.id} readOnly={readOnly} /> : null
|
|
390
|
+
}
|
|
391
|
+
/>
|
|
392
|
+
```
|
|
393
|
+
|
|
376
394
|
The canvas pans, zooms and fits without going below `READABLE_ZOOM`; loop bodies and fan-out branches fold, and a sub-flow shows in place. Every drag has a keyboard equivalent: Tab to a node or edge, Enter to select, arrows to move, Delete to remove, and Add and Connect in its toolbar.
|
|
377
395
|
|
|
378
396
|
`@xyflow/react` comes with the package; its structural CSS is in the preset.
|
package/dist/index.d.ts
CHANGED
|
@@ -76,7 +76,7 @@ export { ReviewPanel, type ReviewPanelProps } from "./workflow/ReviewPanel.js";
|
|
|
76
76
|
export { MediaReview, formatTimecode, type MediaAnchor, type MediaCaptionTrack, type MediaComment, type MediaRegion, type MediaReviewProps, } from "./workflow/MediaReview.js";
|
|
77
77
|
export { DocumentReview, type DocumentAnnotation, type DocumentBlock, type DocumentPage, type DocumentReviewProps, type DocumentSegment, type DocumentSignOff, type SignOffStatus, } from "./workflow/DocumentReview.js";
|
|
78
78
|
export { FormReview, type FormField, type FormFieldBox, type FormFieldStatus, type FormReviewProps, type FormSourcePage, } from "./workflow/FormReview.js";
|
|
79
|
-
export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, type WorkflowCase, type WorkflowCheckNode, type WorkflowClassifierNode, type WorkflowClassifierOption, type WorkflowCondition, type WorkflowConditionNode, type WorkflowConnectReason, type WorkflowConnectResult, type WorkflowConnection, type WorkflowDataType, type WorkflowDefinition, type WorkflowEdge, type WorkflowEvaluatorNode, type WorkflowFact, type WorkflowFactType, type WorkflowFanoutNode, type WorkflowFlow, type WorkflowGateField, type WorkflowGateKind, type WorkflowGateNode, type WorkflowGatePolicy, type WorkflowGotoNode, type WorkflowJoinNode, type WorkflowJudgeNode, type WorkflowModuleSpec, type WorkflowNode, type WorkflowNodeKind, type WorkflowNodeOf, type WorkflowNodeTemplate, type WorkflowOperator, type WorkflowOption, type WorkflowPort, type WorkflowPosition, type WorkflowRegistry, type WorkflowSelection, type WorkflowSelector, type WorkflowStepNode, type WorkflowStepPreset, type WorkflowSubflowNode, type WorkflowVariant, type WorkflowVariantsNode, } from "./workflow/definition.js";
|
|
79
|
+
export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_FLOW_NAME, WORKFLOW_MAX_FLOWS, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addFlow, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, flowNameProblem, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, renameFlow, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, type WorkflowCase, type WorkflowCheckNode, type WorkflowClassifierNode, type WorkflowClassifierOption, type WorkflowCondition, type WorkflowConditionNode, type WorkflowConnectReason, type WorkflowConnectResult, type WorkflowConnection, type WorkflowDataType, type WorkflowDefinition, type WorkflowEdge, type WorkflowEvaluatorNode, type WorkflowFact, type WorkflowFactType, type WorkflowFanoutNode, type WorkflowFlow, type WorkflowFlowNameProblem, type WorkflowGateField, type WorkflowGateKind, type WorkflowGateNode, type WorkflowGatePolicy, type WorkflowGotoNode, type WorkflowJoinNode, type WorkflowJudgeNode, type WorkflowModuleSpec, type WorkflowNode, type WorkflowNodeKind, type WorkflowNodeOf, type WorkflowNodeTemplate, type WorkflowOperator, type WorkflowOption, type WorkflowPort, type WorkflowPosition, type WorkflowRegistry, type WorkflowSelection, type WorkflowSelector, type WorkflowStepNode, type WorkflowStepPreset, type WorkflowSubflowNode, type WorkflowVariant, type WorkflowVariantsNode, } from "./workflow/definition.js";
|
|
80
80
|
export { WORKFLOW_PROBLEM_CODES, blockingProblems, checkDefinition, definitionAvailability, nodeAvailability, type WorkflowProblem, type WorkflowProblemCode, type WorkflowProblemLevel, } from "./workflow/problems.js";
|
|
81
81
|
export { WORKFLOW_PACKAGE_FORMAT, WORKFLOW_PACKAGE_VERSION, diffDefinitions, exportWorkflowPackage, previewWorkflowImport, readWorkflowDefinition, readWorkflowPackage, requirementsOf, sameDefinition, type WorkflowDefinitionDiff, type WorkflowDiffEdge, type WorkflowDiffItem, type WorkflowDisabledNode, type WorkflowFlowDiff, type WorkflowImportSummary, type WorkflowModuleGap, type WorkflowPackage, type WorkflowReadError, type WorkflowReadResult, type WorkflowRequirements, } from "./workflow/portable.js";
|
|
82
82
|
export { NODE_HEADER, NODE_WIDTH, PORT_ROW, arrangeFlow, layoutFlow, nodeSize, portRows, ranksOf, regionsOf, type WorkflowRegion, } from "./workflow/layout.js";
|
|
@@ -84,7 +84,7 @@ export { insertPaletteItem, paletteOf, type WorkflowPaletteGroup, type WorkflowP
|
|
|
84
84
|
export { READABLE_ZOOM, WorkflowCanvas, type WorkflowCanvasProps, } from "./workflow/canvas/WorkflowCanvas.js";
|
|
85
85
|
export { WorkflowPalette, type WorkflowPaletteProps } from "./workflow/editor/WorkflowPalette.js";
|
|
86
86
|
export { WorkflowProblems, problemTarget, type WorkflowProblemsProps, } from "./workflow/editor/WorkflowProblems.js";
|
|
87
|
-
export { ConditionEditor, GatePolicyEditor, WorkflowInspector, type WorkflowInspectorProps, } from "./workflow/editor/WorkflowInspector.js";
|
|
87
|
+
export { ConditionEditor, GatePolicyEditor, WorkflowInspector, type WorkflowInspectorProps, type WorkflowNodeFieldsContext, } from "./workflow/editor/WorkflowInspector.js";
|
|
88
88
|
export { WorkflowJsonView, type WorkflowJsonViewProps, } from "./workflow/editor/WorkflowJsonView.js";
|
|
89
89
|
export { documentStatus, type WorkflowDocumentStatus, type WorkflowRevision, } from "./workflow/workspace.js";
|
|
90
90
|
export { WorkflowDiff, type WorkflowDiffProps } from "./workflow/editor/WorkflowDiff.js";
|
package/dist/index.js
CHANGED
|
@@ -85,7 +85,7 @@ export { ReviewPanel } from "./workflow/ReviewPanel.js";
|
|
|
85
85
|
export { MediaReview, formatTimecode, } from "./workflow/MediaReview.js";
|
|
86
86
|
export { DocumentReview, } from "./workflow/DocumentReview.js";
|
|
87
87
|
export { FormReview, } from "./workflow/FormReview.js";
|
|
88
|
-
export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, } from "./workflow/definition.js";
|
|
88
|
+
export { IN, LOOP_IN, LOOP_OUT, OUT, WORKFLOW_DEFINITION_VERSION, WORKFLOW_FLOW_NAME, WORKFLOW_MAX_FLOWS, WORKFLOW_NODE_KINDS, WORKFLOW_SELECTORS, addFlow, addNode, canConnect, createNode, dataTypes, downstream, edgeId, exitLabel, exitsOf, findFact, findGateKind, findPreset, flowNameProblem, freshId, inputsOf, isJudge, moveNode, operatorsFor, outputsOf, portOf, removeEdge, removeNode, renameFlow, startOf, typeAccepts, updateEdge, updateNode, upstream, withFlow, } from "./workflow/definition.js";
|
|
89
89
|
export { WORKFLOW_PROBLEM_CODES, blockingProblems, checkDefinition, definitionAvailability, nodeAvailability, } from "./workflow/problems.js";
|
|
90
90
|
export { WORKFLOW_PACKAGE_FORMAT, WORKFLOW_PACKAGE_VERSION, diffDefinitions, exportWorkflowPackage, previewWorkflowImport, readWorkflowDefinition, readWorkflowPackage, requirementsOf, sameDefinition, } from "./workflow/portable.js";
|
|
91
91
|
export { NODE_HEADER, NODE_WIDTH, PORT_ROW, arrangeFlow, layoutFlow, nodeSize, portRows, ranksOf, regionsOf, } from "./workflow/layout.js";
|
|
@@ -2,7 +2,7 @@ import type { ProductTheme } from "../appearance/product.js";
|
|
|
2
2
|
import type { WorkflowQuestionState } from "../workflow/question.js";
|
|
3
3
|
import type { WorkflowAction, WorkflowAvailability, WorkflowScope, WorkflowStatus } from "../workflow/model.js";
|
|
4
4
|
import type { ArtifactStatus, GateStatus, ReviewDecision, ReviewVerdict, RunStageStatus } from "../workflow/run.js";
|
|
5
|
-
import type { WorkflowConnectReason, WorkflowNode, WorkflowNodeKind, WorkflowSelector } from "../workflow/definition.js";
|
|
5
|
+
import type { WorkflowConnectReason, WorkflowFlowNameProblem, WorkflowNode, WorkflowNodeKind, WorkflowSelector } from "../workflow/definition.js";
|
|
6
6
|
/** The names a node's summary line uses, looked up by the caller. */
|
|
7
7
|
export interface WorkflowSummaryNames {
|
|
8
8
|
/** A step's or variants' preset, and its module. */
|
|
@@ -394,6 +394,22 @@ export interface Messages {
|
|
|
394
394
|
importing: string;
|
|
395
395
|
importedNotice: string;
|
|
396
396
|
importAvailability: (status: WorkflowAvailability["status"]) => string;
|
|
397
|
+
newFlow: string;
|
|
398
|
+
newFlowTitle: string;
|
|
399
|
+
newFlowDescription: string;
|
|
400
|
+
createFlow: string;
|
|
401
|
+
renameFlow: string;
|
|
402
|
+
renameFlowTitle: (name: string) => string;
|
|
403
|
+
renameFlowDescription: string;
|
|
404
|
+
rename: string;
|
|
405
|
+
flowName: string;
|
|
406
|
+
flowNameHint: string;
|
|
407
|
+
flowLabel: string;
|
|
408
|
+
flowLabelHint: string;
|
|
409
|
+
flowNameProblem: (problem: WorkflowFlowNameProblem) => string;
|
|
410
|
+
flowsFull: (max: number) => string;
|
|
411
|
+
flowCreatedNotice: (name: string) => string;
|
|
412
|
+
flowRenamedNotice: (name: string) => string;
|
|
397
413
|
modules: string;
|
|
398
414
|
noModules: string;
|
|
399
415
|
moduleStatus: (status: "available" | "missing" | "version" | "unavailable", installed?: string) => string;
|
|
@@ -600,6 +600,26 @@ export const defaultMessages = {
|
|
|
600
600
|
importAction: "Import",
|
|
601
601
|
importing: "Importing",
|
|
602
602
|
importedNotice: "Imported.",
|
|
603
|
+
newFlow: "New flow",
|
|
604
|
+
newFlowTitle: "New flow",
|
|
605
|
+
newFlowDescription: "An empty flow beside the others. A sub-flow node runs it.",
|
|
606
|
+
createFlow: "Create flow",
|
|
607
|
+
renameFlow: "Rename flow",
|
|
608
|
+
renameFlowTitle: (name) => `Rename ${name}`,
|
|
609
|
+
renameFlowDescription: "Sub-flow nodes that run it, and the main flow, follow the new name.",
|
|
610
|
+
rename: "Rename",
|
|
611
|
+
flowName: "Name",
|
|
612
|
+
flowNameHint: "Lowercase letters, digits and dashes, starting with a letter. Up to 32.",
|
|
613
|
+
flowLabel: "Label",
|
|
614
|
+
flowLabelHint: "How the flow is shown. Its name when empty.",
|
|
615
|
+
flowNameProblem: (problem) => ({
|
|
616
|
+
empty: "Give the flow a name.",
|
|
617
|
+
format: "Use lowercase letters, digits and dashes, starting with a letter, up to 32.",
|
|
618
|
+
taken: "Another flow has this name.",
|
|
619
|
+
})[problem],
|
|
620
|
+
flowsFull: (max) => `A workflow holds at most ${count(max)} flows.`,
|
|
621
|
+
flowCreatedNotice: (name) => `Flow ${name} created.`,
|
|
622
|
+
flowRenamedNotice: (name) => `Flow renamed to ${name}.`,
|
|
603
623
|
importAvailability: (status) => ({
|
|
604
624
|
available: "Runs here",
|
|
605
625
|
"missing-capabilities": "Missing capabilities here",
|
|
@@ -191,6 +191,35 @@ function CanvasInner({ definition, flow: flowProp, registry, onChange, readOnly:
|
|
|
191
191
|
const y = b.height * zoom <= h - pad * 2 ? (h - b.height * zoom) / 2 - b.y * zoom : pad - b.y * zoom;
|
|
192
192
|
void view.setViewport({ x, y, zoom });
|
|
193
193
|
}, [view]);
|
|
194
|
+
/**
|
|
195
|
+
* Centers a node that is out of view at the current zoom. `whole`: unless
|
|
196
|
+
* all of it shows (a deep link); otherwise unless its middle shows, so
|
|
197
|
+
* choosing a node at the edge does not move the canvas.
|
|
198
|
+
*/
|
|
199
|
+
const reveal = React.useCallback((id, whole = false) => {
|
|
200
|
+
const el = pane.current;
|
|
201
|
+
const n = view.getInternalNode(id);
|
|
202
|
+
if (!el || !n || !el.clientWidth || !el.clientHeight)
|
|
203
|
+
return;
|
|
204
|
+
const { x, y, zoom } = view.getViewport();
|
|
205
|
+
const w = n.measured.width ?? 0;
|
|
206
|
+
const h = n.measured.height ?? 0;
|
|
207
|
+
const left = n.internals.positionAbsolute.x * zoom + x;
|
|
208
|
+
const top = n.internals.positionAbsolute.y * zoom + y;
|
|
209
|
+
const [x0, y0, x1, y1] = whole
|
|
210
|
+
? [left, top, left + w * zoom, top + h * zoom]
|
|
211
|
+
: [
|
|
212
|
+
left + (w * zoom) / 2,
|
|
213
|
+
top + (h * zoom) / 2,
|
|
214
|
+
left + (w * zoom) / 2,
|
|
215
|
+
top + (h * zoom) / 2,
|
|
216
|
+
];
|
|
217
|
+
if (x0 >= 0 && y0 >= 0 && x1 <= el.clientWidth && y1 <= el.clientHeight)
|
|
218
|
+
return;
|
|
219
|
+
const cx = n.internals.positionAbsolute.x + w / 2;
|
|
220
|
+
const cy = n.internals.positionAbsolute.y + h / 2;
|
|
221
|
+
void view.setCenter(cx, cy, { zoom });
|
|
222
|
+
}, [view]);
|
|
194
223
|
const flowName = flowProp && definition.flows[flowProp] ? flowProp : definition.main;
|
|
195
224
|
const flow = definition.flows[flowName] ?? EMPTY_FLOW;
|
|
196
225
|
const readOnly = readOnlyProp || !onChange;
|
|
@@ -218,6 +247,17 @@ function CanvasInner({ definition, flow: flowProp, registry, onChange, readOnly:
|
|
|
218
247
|
const selectedNode = selection?.type === "node" && selection.flow === flowName
|
|
219
248
|
? flow.nodes.find((n) => n.id === selection.id)
|
|
220
249
|
: undefined;
|
|
250
|
+
// A node selected from outside the canvas (a deep link, a problem) comes into view.
|
|
251
|
+
const selectedId = selectedNode?.id;
|
|
252
|
+
React.useEffect(() => {
|
|
253
|
+
if (selectedId)
|
|
254
|
+
reveal(selectedId);
|
|
255
|
+
}, [selectedId, reveal]);
|
|
256
|
+
const init = React.useCallback(() => {
|
|
257
|
+
place();
|
|
258
|
+
if (selectedId)
|
|
259
|
+
reveal(selectedId, true);
|
|
260
|
+
}, [place, reveal, selectedId]);
|
|
221
261
|
const selectedEdge = selection?.type === "edge" && selection.flow === flowName
|
|
222
262
|
? flow.edges.find((e) => edgeId(e) === selection.id)
|
|
223
263
|
: undefined;
|
|
@@ -624,7 +664,7 @@ function CanvasInner({ definition, flow: flowProp, registry, onChange, readOnly:
|
|
|
624
664
|
: m.kind(item.template.kind)), item.available ? null : (_jsx("span", { className: "ml-auto text-2xs text-muted-foreground", children: m.notRun }))] }, item.key)))] }, g.key))) })] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !selectedNode, onClick: () => setConnecting(true), children: [_jsx(CableIcon, { "aria-hidden": true }), m.connectAction] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !selectedNode && !selectedEdge, onClick: () => remove(selectedNode ? [selectedNode.id] : [], selectedEdge ? [edgeId(selectedEdge)] : []), children: [_jsx(Trash2Icon, { "aria-hidden": true }), m.deleteSelection] }), _jsxs(Button, { size: "sm", variant: "outline", disabled: !flow.nodes.length, onClick: () => {
|
|
625
665
|
change(arrangeFlow(flow, registry, definition));
|
|
626
666
|
window.requestAnimationFrame(place);
|
|
627
|
-
}, children: [_jsx(LayoutGridIcon, { "aria-hidden": true }), m.arrange] })] })), _jsxs("span", { className: "ml-auto flex items-center gap-1", children: [_jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomOut, title: m.zoomOut, onClick: () => void view.zoomOut(), children: _jsx(ZoomOutIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomIn, title: m.zoomIn, onClick: () => void view.zoomIn(), children: _jsx(ZoomInIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.fit, title: m.fit, onClick: place, children: _jsx(MaximizeIcon, { "aria-hidden": true }) })] })] }), _jsx("div", { ref: pane, className: "relative min-h-0 flex-1", children: _jsx(ReactFlow, { nodes: nodes, edges: built.edges, nodeTypes: NODE_TYPES, onInit:
|
|
667
|
+
}, children: [_jsx(LayoutGridIcon, { "aria-hidden": true }), m.arrange] })] })), _jsxs("span", { className: "ml-auto flex items-center gap-1", children: [_jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomOut, title: m.zoomOut, onClick: () => void view.zoomOut(), children: _jsx(ZoomOutIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.zoomIn, title: m.zoomIn, onClick: () => void view.zoomIn(), children: _jsx(ZoomInIcon, { "aria-hidden": true }) }), _jsx(Button, { size: "icon-sm", variant: "ghost", "aria-label": m.fit, title: m.fit, onClick: place, children: _jsx(MaximizeIcon, { "aria-hidden": true }) })] })] }), _jsx("div", { ref: pane, className: "relative min-h-0 flex-1", children: _jsx(ReactFlow, { nodes: nodes, edges: built.edges, nodeTypes: NODE_TYPES, onInit: init, minZoom: READABLE_ZOOM, maxZoom: 1.5, panOnScroll: true, snapToGrid: true, snapGrid: [GRID, GRID], nodesDraggable: !readOnly, nodesConnectable: !readOnly, nodesFocusable: true, edgesFocusable: true, elementsSelectable: true, multiSelectionKeyCode: null, selectionKeyCode: null, deleteKeyCode: readOnly ? null : ["Backspace", "Delete"], ariaLabelConfig: {
|
|
628
668
|
"node.a11yDescription.default": m.nodeHelp,
|
|
629
669
|
"node.a11yDescription.keyboardDisabled": m.nodeHelp,
|
|
630
670
|
"node.a11yDescription.ariaLiveMessage": ({ direction, x, y }) => m.movedLive(direction, x, y),
|
|
@@ -405,4 +405,24 @@ export interface WorkflowSelection {
|
|
|
405
405
|
flow: string;
|
|
406
406
|
id: string;
|
|
407
407
|
}
|
|
408
|
+
/** A flow's name, its key in `flows`: a lowercase slug of up to 32 characters ("build", "escalation"). */
|
|
409
|
+
export declare const WORKFLOW_FLOW_NAME: RegExp;
|
|
410
|
+
/** The most flows one definition holds. */
|
|
411
|
+
export declare const WORKFLOW_MAX_FLOWS = 20;
|
|
412
|
+
/** Why a flow name is refused. The editor's copy names each one. */
|
|
413
|
+
export type WorkflowFlowNameProblem = "empty" | "format" | "taken";
|
|
414
|
+
/**
|
|
415
|
+
* Whether `name` can name a flow of the definition: a slug by
|
|
416
|
+
* `WORKFLOW_FLOW_NAME` that no other flow has. `current` is the flow being
|
|
417
|
+
* renamed, which may keep its own name. Null when it can.
|
|
418
|
+
*/
|
|
419
|
+
export declare function flowNameProblem(definition: WorkflowDefinition, name: string, current?: string): WorkflowFlowNameProblem | null;
|
|
420
|
+
/** The definition with a new, empty flow. A label that is empty is left out. */
|
|
421
|
+
export declare function addFlow(definition: WorkflowDefinition, name: string, label?: string): WorkflowDefinition;
|
|
422
|
+
/**
|
|
423
|
+
* Renames a flow everywhere the definition names it: its key, `main`, and
|
|
424
|
+
* the sub-flow nodes that run it. `label` replaces the flow's label; an empty
|
|
425
|
+
* one removes it; omitted, the label stays. Flows keep their order.
|
|
426
|
+
*/
|
|
427
|
+
export declare function renameFlow(definition: WorkflowDefinition, from: string, to: string, label?: string): WorkflowDefinition;
|
|
408
428
|
export {};
|
|
@@ -409,3 +409,47 @@ export function updateEdge(flow, id, patch) {
|
|
|
409
409
|
export function withFlow(definition, name, flow) {
|
|
410
410
|
return { ...definition, flows: { ...definition.flows, [name]: flow } };
|
|
411
411
|
}
|
|
412
|
+
// ---------------------------------------------------------------- flows
|
|
413
|
+
/** A flow's name, its key in `flows`: a lowercase slug of up to 32 characters ("build", "escalation"). */
|
|
414
|
+
export const WORKFLOW_FLOW_NAME = /^[a-z][a-z0-9-]{0,31}$/;
|
|
415
|
+
/** The most flows one definition holds. */
|
|
416
|
+
export const WORKFLOW_MAX_FLOWS = 20;
|
|
417
|
+
/**
|
|
418
|
+
* Whether `name` can name a flow of the definition: a slug by
|
|
419
|
+
* `WORKFLOW_FLOW_NAME` that no other flow has. `current` is the flow being
|
|
420
|
+
* renamed, which may keep its own name. Null when it can.
|
|
421
|
+
*/
|
|
422
|
+
export function flowNameProblem(definition, name, current) {
|
|
423
|
+
if (!name)
|
|
424
|
+
return "empty";
|
|
425
|
+
if (!WORKFLOW_FLOW_NAME.test(name))
|
|
426
|
+
return "format";
|
|
427
|
+
if (name !== current && Object.hasOwn(definition.flows, name))
|
|
428
|
+
return "taken";
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
/** The definition with a new, empty flow. A label that is empty is left out. */
|
|
432
|
+
export function addFlow(definition, name, label) {
|
|
433
|
+
const flow = { ...(label ? { label } : {}), nodes: [], edges: [] };
|
|
434
|
+
return withFlow(definition, name, flow);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Renames a flow everywhere the definition names it: its key, `main`, and
|
|
438
|
+
* the sub-flow nodes that run it. `label` replaces the flow's label; an empty
|
|
439
|
+
* one removes it; omitted, the label stays. Flows keep their order.
|
|
440
|
+
*/
|
|
441
|
+
export function renameFlow(definition, from, to, label) {
|
|
442
|
+
if (!definition.flows[from])
|
|
443
|
+
return definition;
|
|
444
|
+
const flows = {};
|
|
445
|
+
for (const [name, f] of Object.entries(definition.flows)) {
|
|
446
|
+
const nodes = f.nodes.map((n) => n.kind === "subflow" && n.flow === from ? { ...n, flow: to } : n);
|
|
447
|
+
let flow = { ...f, nodes };
|
|
448
|
+
if (name === from && label !== undefined) {
|
|
449
|
+
const { label: _old, ...rest } = flow;
|
|
450
|
+
flow = label ? { ...rest, label } : rest;
|
|
451
|
+
}
|
|
452
|
+
flows[name === from ? to : name] = flow;
|
|
453
|
+
}
|
|
454
|
+
return { ...definition, main: definition.main === from ? to : definition.main, flows };
|
|
455
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import { type WorkflowCondition, type WorkflowDefinition, type WorkflowFlow, type WorkflowGatePolicy, type WorkflowRegistry, type WorkflowSelection } from "../definition.js";
|
|
2
|
+
import { type WorkflowCondition, type WorkflowDefinition, type WorkflowFlow, type WorkflowGatePolicy, type WorkflowNode, type WorkflowRegistry, type WorkflowSelection } from "../definition.js";
|
|
3
3
|
export interface WorkflowInspectorProps {
|
|
4
4
|
definition: WorkflowDefinition;
|
|
5
5
|
registry: WorkflowRegistry;
|
|
@@ -9,8 +9,25 @@ export interface WorkflowInspectorProps {
|
|
|
9
9
|
/** After a delete from the inspector, the selection is cleared through this. */
|
|
10
10
|
onSelectionChange?: (selection: WorkflowSelection | null) => void;
|
|
11
11
|
readOnly?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The host's own fields for the selected node, after the fields of its
|
|
14
|
+
* kind: a runner picker, what a step inherits. Switch on `node.kind`;
|
|
15
|
+
* return null to add nothing. It is called while the inspector renders, so
|
|
16
|
+
* fields that need hooks go in a component it returns.
|
|
17
|
+
*/
|
|
18
|
+
renderNodeFields?: (context: WorkflowNodeFieldsContext) => React.ReactNode;
|
|
12
19
|
className?: string;
|
|
13
20
|
}
|
|
21
|
+
/** What the inspector hands `renderNodeFields`. */
|
|
22
|
+
export interface WorkflowNodeFieldsContext {
|
|
23
|
+
node: WorkflowNode;
|
|
24
|
+
/** The flow the node is in. */
|
|
25
|
+
flow: string;
|
|
26
|
+
definition: WorkflowDefinition;
|
|
27
|
+
/** Patches the node, as the inspector's own fields do. Does nothing when read only. */
|
|
28
|
+
onChange: (patch: Partial<WorkflowNode>) => void;
|
|
29
|
+
readOnly: boolean;
|
|
30
|
+
}
|
|
14
31
|
/** One comparison on a run fact: the fact, its node when it is a node's, the operator and the value. */
|
|
15
32
|
export declare function ConditionEditor({ condition, onChange, flow, registry, label, disabled, }: {
|
|
16
33
|
condition: WorkflowCondition;
|
|
@@ -34,7 +51,8 @@ export declare function GatePolicyEditor({ policy, onChange, registry, approvers
|
|
|
34
51
|
* The selected node's or connection's fields, edited in place: per-kind
|
|
35
52
|
* fields, a condition editor over the host's facts and operators, a gate
|
|
36
53
|
* policy editor (roles, members, quorum, timeout), and the host's model,
|
|
37
|
-
* harness and effort pickers
|
|
54
|
+
* harness and effort pickers, then any fields the host adds for the node
|
|
55
|
+
* (`renderNodeFields`). With nothing selected, the workflow's name and
|
|
38
56
|
* description. Every edit goes out as the whole next definition.
|
|
39
57
|
*/
|
|
40
|
-
export declare function WorkflowInspector({ definition, registry, selection, onChange, onSelectionChange, readOnly, className, }: WorkflowInspectorProps): React.JSX.Element;
|
|
58
|
+
export declare function WorkflowInspector({ definition, registry, selection, onChange, onSelectionChange, readOnly, renderNodeFields, className, }: WorkflowInspectorProps): React.JSX.Element;
|
|
@@ -149,10 +149,11 @@ function PortsEditor({ legend, ports, onChange, registry, disabled, }) {
|
|
|
149
149
|
* The selected node's or connection's fields, edited in place: per-kind
|
|
150
150
|
* fields, a condition editor over the host's facts and operators, a gate
|
|
151
151
|
* policy editor (roles, members, quorum, timeout), and the host's model,
|
|
152
|
-
* harness and effort pickers
|
|
152
|
+
* harness and effort pickers, then any fields the host adds for the node
|
|
153
|
+
* (`renderNodeFields`). With nothing selected, the workflow's name and
|
|
153
154
|
* description. Every edit goes out as the whole next definition.
|
|
154
155
|
*/
|
|
155
|
-
export function WorkflowInspector({ definition, registry, selection = null, onChange, onSelectionChange, readOnly = false, className, }) {
|
|
156
|
+
export function WorkflowInspector({ definition, registry, selection = null, onChange, onSelectionChange, readOnly = false, renderNodeFields, className, }) {
|
|
156
157
|
const { messages } = useUI();
|
|
157
158
|
const m = messages.editor;
|
|
158
159
|
const disabled = readOnly || !onChange;
|
|
@@ -165,7 +166,14 @@ export function WorkflowInspector({ definition, registry, selection = null, onCh
|
|
|
165
166
|
let title;
|
|
166
167
|
if (node) {
|
|
167
168
|
title = m.inspectorFor(m.kind(node.kind), node.label);
|
|
168
|
-
|
|
169
|
+
const patch = (p) => setFlow(updateNode(flow, node.id, p));
|
|
170
|
+
body = (_jsx(NodeFields, { node: node, flow: flow, definition: definition, registry: registry, disabled: disabled, host: renderNodeFields?.({
|
|
171
|
+
node,
|
|
172
|
+
flow: flowName,
|
|
173
|
+
definition,
|
|
174
|
+
onChange: disabled ? () => { } : patch,
|
|
175
|
+
readOnly: disabled,
|
|
176
|
+
}), onPatch: patch, onDelete: () => {
|
|
169
177
|
setFlow(removeNode(flow, node.id));
|
|
170
178
|
onSelectionChange?.(null);
|
|
171
179
|
} }));
|
|
@@ -187,7 +195,7 @@ export function WorkflowInspector({ definition, registry, selection = null, onCh
|
|
|
187
195
|
}
|
|
188
196
|
return (_jsxs("section", { "aria-label": m.inspector, "data-slot": "workflow-inspector", className: cn("flex min-h-0 flex-col gap-3", className), children: [_jsx("h2", { className: "truncate text-sm font-medium", children: title }), _jsx("div", { className: "flex min-h-0 flex-col gap-4 overflow-y-auto pb-2", children: body })] }));
|
|
189
197
|
}
|
|
190
|
-
function NodeFields({ node, flow, definition, registry, disabled, onPatch, onDelete, }) {
|
|
198
|
+
function NodeFields({ node, flow, definition, registry, disabled, host, onPatch, onDelete, }) {
|
|
191
199
|
const { messages } = useUI();
|
|
192
200
|
const m = messages.editor;
|
|
193
201
|
const presetOptions = registry.modules.flatMap((mod) => mod.presets.map((p) => ({ value: `${mod.id}/${p.id}`, label: `${mod.label} · ${p.label}` })));
|
|
@@ -327,7 +335,7 @@ function NodeFields({ node, flow, definition, registry, disabled, onPatch, onDel
|
|
|
327
335
|
fields = (_jsxs(_Fragment, { children: [_jsx(Field, { label: m.question, children: _jsx(Textarea, { value: node.question, disabled: disabled, onChange: (e) => onPatch({ question: e.target.value }) }) }), _jsx(Field, { label: m.yesAt, children: _jsx(PercentInput, { value: node.threshold, disabled: disabled, onValue: (threshold) => onPatch({ threshold: threshold ?? 0.5 }) }) })] }));
|
|
328
336
|
break;
|
|
329
337
|
}
|
|
330
|
-
return (_jsxs("div", { className: "flex flex-col gap-4", children: [common, fields, _jsx(Field, { label: m.note, children: _jsx(Textarea, { value: node.note ?? "", disabled: disabled, onChange: (e) => onPatch({ note: e.target.value || undefined }) }) }), disabled ? null : (_jsxs(Button, { type: "button", variant: "destructive", size: "sm", className: "self-start", onClick: onDelete, children: [_jsx(Trash2Icon, { "aria-hidden": true }), m.deleteNode(node.label)] }))] }));
|
|
338
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [common, fields, host == null || host === false ? null : (_jsx("div", { "data-slot": "workflow-inspector-host-fields", className: "flex flex-col gap-4", children: host })), _jsx(Field, { label: m.note, children: _jsx(Textarea, { value: node.note ?? "", disabled: disabled, onChange: (e) => onPatch({ note: e.target.value || undefined }) }) }), disabled ? null : (_jsxs(Button, { type: "button", variant: "destructive", size: "sm", className: "self-start", onClick: onDelete, children: [_jsx(Trash2Icon, { "aria-hidden": true }), m.deleteNode(node.label)] }))] }));
|
|
331
339
|
}
|
|
332
340
|
/** A preset's contract, read from the registry. */
|
|
333
341
|
function PortSummary({ node, definition, registry, }) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type WorkflowDefinition, type WorkflowRegistry, type WorkflowSelection } from "../definition.js";
|
|
3
3
|
import { type WorkflowImportSummary as ImportPreview } from "../portable.js";
|
|
4
4
|
import { type WorkflowRevision } from "../workspace.js";
|
|
5
|
+
import { type WorkflowInspectorProps } from "./WorkflowInspector.js";
|
|
5
6
|
/** A host action: may return a promise; a rejection's message is shown. */
|
|
6
7
|
type HostAction<A extends unknown[]> = (...args: A) => Promise<unknown> | unknown;
|
|
7
8
|
export interface WorkflowWorkspaceProps {
|
|
@@ -26,6 +27,28 @@ export interface WorkflowWorkspaceProps {
|
|
|
26
27
|
importPreview?: ImportPreview | null;
|
|
27
28
|
onImportConfirm?: HostAction<[]>;
|
|
28
29
|
onImportCancel?: () => void;
|
|
30
|
+
/**
|
|
31
|
+
* What is selected, held by the host: a deep link to a node opens its flow
|
|
32
|
+
* with the node selected and in view. Uncontrolled when omitted.
|
|
33
|
+
*/
|
|
34
|
+
selection?: WorkflowSelection | null;
|
|
35
|
+
/** Where the editor opens when the host does not hold the selection. */
|
|
36
|
+
defaultSelection?: WorkflowSelection | null;
|
|
37
|
+
onSelectionChange?: (selection: WorkflowSelection | null) => void;
|
|
38
|
+
/**
|
|
39
|
+
* Offers New flow. The workspace asks for a name (`WORKFLOW_FLOW_NAME`, not
|
|
40
|
+
* taken) and an optional label, and hands the host the definition with the
|
|
41
|
+
* flow added. The host applies it, with anything it keeps beside it.
|
|
42
|
+
*/
|
|
43
|
+
onFlowCreate?: HostAction<[definition: WorkflowDefinition, name: string]>;
|
|
44
|
+
/**
|
|
45
|
+
* Offers Rename flow, for the open flow. The definition handed over has the
|
|
46
|
+
* flow renamed everywhere it names it (`renameFlow`): its key, `main` and
|
|
47
|
+
* the sub-flow nodes that run it. Anything else that names flows is the host's.
|
|
48
|
+
*/
|
|
49
|
+
onFlowRename?: HostAction<[definition: WorkflowDefinition, from: string, to: string]>;
|
|
50
|
+
/** The host's own fields for the selected node in the inspector. */
|
|
51
|
+
renderNodeFields?: WorkflowInspectorProps["renderNodeFields"];
|
|
29
52
|
readOnly?: boolean;
|
|
30
53
|
loading?: boolean;
|
|
31
54
|
error?: string | {
|
|
@@ -42,8 +65,10 @@ export interface WorkflowWorkspaceProps {
|
|
|
42
65
|
* there are no edits, and when the saved revision moves under unsaved edits
|
|
43
66
|
* it stops and asks (use theirs, keep mine, compare). Publishing shows the
|
|
44
67
|
* draft against live first and is held while anything blocks. Save, publish,
|
|
45
|
-
* save as, import and export are the host's; it never persists anything.
|
|
46
|
-
*
|
|
68
|
+
* save as, import and export are the host's; it never persists anything. A
|
|
69
|
+
* host may hold the selection (deep links), let people add and rename flows,
|
|
70
|
+
* and add its own fields to the inspector. On narrow containers the palette
|
|
71
|
+
* and the side panel open as drawers.
|
|
47
72
|
*/
|
|
48
73
|
export declare function WorkflowWorkspace(props: WorkflowWorkspaceProps): React.JSX.Element;
|
|
49
74
|
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { CopyPlusIcon, DownloadIcon, ListTreeIcon, PanelRightIcon, RocketIcon, SaveIcon, UndoIcon, UploadIcon, } from "lucide-react";
|
|
3
|
+
import { CopyPlusIcon, DownloadIcon, ListTreeIcon, PanelRightIcon, PencilIcon, PlusIcon, RocketIcon, SaveIcon, UndoIcon, UploadIcon, } from "lucide-react";
|
|
4
4
|
import * as React from "react";
|
|
5
5
|
import { cn } from "../../lib/cn.js";
|
|
6
6
|
import { ConfirmDialog } from "../../overlays/ConfirmDialog.js";
|
|
@@ -16,6 +16,7 @@ import { ErrorState } from "../../states/ErrorState.js";
|
|
|
16
16
|
import { LoadingState } from "../../states/LoadingState.js";
|
|
17
17
|
import { StatusBadge } from "../../status/StatusBadge.js";
|
|
18
18
|
import { WorkflowCanvas } from "../canvas/WorkflowCanvas.js";
|
|
19
|
+
import { WORKFLOW_MAX_FLOWS, addFlow, flowNameProblem, renameFlow, } from "../definition.js";
|
|
19
20
|
import { insertPaletteItem } from "../palette.js";
|
|
20
21
|
import { diffDefinitions, sameDefinition, } from "../portable.js";
|
|
21
22
|
import { blockingProblems, checkDefinition } from "../problems.js";
|
|
@@ -26,6 +27,7 @@ import { WorkflowInspector } from "./WorkflowInspector.js";
|
|
|
26
27
|
import { WorkflowJsonView } from "./WorkflowJsonView.js";
|
|
27
28
|
import { WorkflowPalette } from "./WorkflowPalette.js";
|
|
28
29
|
import { WorkflowProblems } from "./WorkflowProblems.js";
|
|
30
|
+
const sameSelection = (a, b) => a === b || (!!a && !!b && a.type === b.type && a.flow === b.flow && a.id === b.id);
|
|
29
31
|
const messageOf = (e) => (e instanceof Error ? e.message : typeof e === "string" ? e : "");
|
|
30
32
|
/**
|
|
31
33
|
* The whole editor on one page: the palette, the canvas and a side panel
|
|
@@ -35,8 +37,10 @@ const messageOf = (e) => (e instanceof Error ? e.message : typeof e === "string"
|
|
|
35
37
|
* there are no edits, and when the saved revision moves under unsaved edits
|
|
36
38
|
* it stops and asks (use theirs, keep mine, compare). Publishing shows the
|
|
37
39
|
* draft against live first and is held while anything blocks. Save, publish,
|
|
38
|
-
* save as, import and export are the host's; it never persists anything.
|
|
39
|
-
*
|
|
40
|
+
* save as, import and export are the host's; it never persists anything. A
|
|
41
|
+
* host may hold the selection (deep links), let people add and rename flows,
|
|
42
|
+
* and add its own fields to the inspector. On narrow containers the palette
|
|
43
|
+
* and the side panel open as drawers.
|
|
40
44
|
*/
|
|
41
45
|
export function WorkflowWorkspace(props) {
|
|
42
46
|
const { messages } = useUI();
|
|
@@ -46,14 +50,22 @@ export function WorkflowWorkspace(props) {
|
|
|
46
50
|
return (_jsx(ErrorState, { variant: "page", title: messages.workspace.loadFailed, error: props.error ?? null, onRetry: props.onRetry, className: props.className }));
|
|
47
51
|
return _jsx(Workspace, { ...props, definition: props.definition });
|
|
48
52
|
}
|
|
49
|
-
function Workspace({ definition, onChange, registry, saved = null, published = null, onSave, onPublish, onSaveAs, onExport, onImport, importPreview = null, onImportConfirm, onImportCancel, readOnly: readOnlyProp = false, className, }) {
|
|
53
|
+
function Workspace({ definition, onChange, registry, saved = null, published = null, onSave, onPublish, onSaveAs, onExport, onImport, importPreview = null, onImportConfirm, onImportCancel, selection: selectionProp, defaultSelection = null, onSelectionChange, onFlowCreate, onFlowRename, renderNodeFields, readOnly: readOnlyProp = false, className, }) {
|
|
50
54
|
const { messages } = useUI();
|
|
51
55
|
const m = messages.workspace;
|
|
52
56
|
const readOnly = readOnlyProp || !onChange;
|
|
53
57
|
const [base, setBase] = React.useState(saved ?? published);
|
|
54
|
-
const [
|
|
58
|
+
const [ownSelection, setOwnSelection] = React.useState(defaultSelection);
|
|
59
|
+
const selection = selectionProp === undefined ? ownSelection : selectionProp;
|
|
60
|
+
const [flowName, setFlowName] = React.useState(selection?.flow ?? definition.main);
|
|
55
61
|
const flow = definition.flows[flowName] ? flowName : definition.main;
|
|
56
|
-
|
|
62
|
+
// A selection in another flow (the host's, or a problem's) opens that flow.
|
|
63
|
+
const [shown, setShown] = React.useState(selection);
|
|
64
|
+
if (!sameSelection(shown, selection)) {
|
|
65
|
+
setShown(selection);
|
|
66
|
+
if (selection && selection.flow !== flowName)
|
|
67
|
+
setFlowName(selection.flow);
|
|
68
|
+
}
|
|
57
69
|
const [panel, setPanel] = React.useState("inspector");
|
|
58
70
|
const [drawer, setDrawer] = React.useState(null);
|
|
59
71
|
const [dialog, setDialog] = React.useState(null);
|
|
@@ -103,9 +115,15 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
|
|
|
103
115
|
}
|
|
104
116
|
};
|
|
105
117
|
const select = (s) => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
if (sameSelection(s, selection))
|
|
119
|
+
return;
|
|
120
|
+
if (selectionProp === undefined)
|
|
121
|
+
setOwnSelection(s);
|
|
122
|
+
onSelectionChange?.(s);
|
|
123
|
+
};
|
|
124
|
+
const openFlow = (next) => {
|
|
125
|
+
setFlowName(next);
|
|
126
|
+
select(null);
|
|
109
127
|
};
|
|
110
128
|
const add = (item) => {
|
|
111
129
|
const label = item.label ||
|
|
@@ -120,7 +138,7 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
|
|
|
120
138
|
const flows = Object.entries(definition.flows);
|
|
121
139
|
const edit = readOnly ? undefined : onChange;
|
|
122
140
|
const palette = (_jsx(WorkflowPalette, { registry: registry, onAdd: readOnly ? undefined : add, className: "min-h-0 flex-1" }));
|
|
123
|
-
const side = (_jsxs(Tabs, { value: panel, onValueChange: (v) => setPanel(v), className: "flex min-h-0 flex-1 flex-col", children: [_jsxs(TabsList, { "aria-label": m.panels, children: [_jsx(TabsTrigger, { value: "inspector", children: messages.editor.inspector }), _jsx(TabsTrigger, { value: "problems", children: m.problemsTab(blocking + warnings) }), _jsx(TabsTrigger, { value: "json", children: m.jsonTab })] }), _jsx(TabsContent, { value: "inspector", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowInspector, { definition: definition, registry: registry, selection: selection, onSelectionChange: select, onChange: edit, readOnly: readOnly }) }), _jsx(TabsContent, { value: "problems", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowProblems, { problems: problems, definition: definition, onSelect: (s) => {
|
|
141
|
+
const side = (_jsxs(Tabs, { value: panel, onValueChange: (v) => setPanel(v), className: "flex min-h-0 flex-1 flex-col", children: [_jsxs(TabsList, { "aria-label": m.panels, children: [_jsx(TabsTrigger, { value: "inspector", children: messages.editor.inspector }), _jsx(TabsTrigger, { value: "problems", children: m.problemsTab(blocking + warnings) }), _jsx(TabsTrigger, { value: "json", children: m.jsonTab })] }), _jsx(TabsContent, { value: "inspector", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowInspector, { definition: definition, registry: registry, selection: selection, onSelectionChange: select, onChange: edit, readOnly: readOnly, renderNodeFields: renderNodeFields }) }), _jsx(TabsContent, { value: "problems", className: "min-h-0 flex-1 overflow-y-auto", children: _jsx(WorkflowProblems, { problems: problems, definition: definition, onSelect: (s) => {
|
|
124
142
|
select(s);
|
|
125
143
|
setPanel("inspector");
|
|
126
144
|
setDrawer(null);
|
|
@@ -131,10 +149,13 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
|
|
|
131
149
|
}, children: [_jsx(CopyPlusIcon, { "aria-hidden": true }), m.saveAs] })) : null, onSave && !readOnly ? (_jsxs(Button, { size: "sm", variant: "outline", disabled: !status.dirty || status.conflict || busy !== null, onClick: () => void run("save", () => onSave(definition, base), m.savedNotice), children: [_jsx(SaveIcon, { "aria-hidden": true }), busy === "save" ? m.saving : m.save] })) : null, onPublish && !readOnly ? (_jsxs(Button, { size: "sm", disabled: status.conflict || busy !== null || (!status.unpublished && !!published), onClick: () => setDialog("publish"), children: [_jsx(RocketIcon, { "aria-hidden": true }), m.publish] })) : null] })] }), failure ? (_jsx("p", { role: "alert", className: "rounded-lg border border-danger-border bg-danger-bg px-3 py-2 text-sm text-danger-fg", children: failure })) : null, notice ? (_jsx("p", { className: "sr-only", role: "status", children: notice })) : null, status.conflict && saved ? (_jsxs("div", { role: "alert", className: "flex flex-wrap items-center gap-2 rounded-lg border border-warning-border bg-warning-bg px-3 py-2 text-sm text-warning-fg", children: [_jsx("span", { className: "min-w-0 flex-1", children: m.conflictDescription(saved.by, saved.label) }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => setDialog("compare"), children: m.compare }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => {
|
|
132
150
|
setBase(saved);
|
|
133
151
|
onChange?.(saved.definition);
|
|
134
|
-
}, children: m.useTheirs }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => setBase(saved), children: m.keepMine })] })) : null, _jsxs("div", { className: "flex min-h-0 flex-1 gap-3", children: [_jsx("aside", { "aria-label": m.paletteRegion, className: "hidden w-60 shrink-0 flex-col @5xl:flex", children: palette }), _jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [!readOnly ? (_jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("palette"), children: [_jsx(ListTreeIcon, { "aria-hidden": true }), messages.editor.palette] })) : null, _jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("panel"), children: [_jsx(PanelRightIcon, { "aria-hidden": true }), m.panels] }), flows.length > 1 ? (_jsx(Segmented, { label: m.flow, size: "sm", value: flow, onValueChange: (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
},
|
|
152
|
+
}, children: m.useTheirs }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => setBase(saved), children: m.keepMine })] })) : null, _jsxs("div", { className: "flex min-h-0 flex-1 gap-3", children: [_jsx("aside", { "aria-label": m.paletteRegion, className: "hidden w-60 shrink-0 flex-col @5xl:flex", children: palette }), _jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col gap-2", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [!readOnly ? (_jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("palette"), children: [_jsx(ListTreeIcon, { "aria-hidden": true }), messages.editor.palette] })) : null, _jsxs(Button, { size: "sm", variant: "outline", className: "@5xl:hidden", onClick: () => setDrawer("panel"), children: [_jsx(PanelRightIcon, { "aria-hidden": true }), m.panels] }), flows.length > 1 ? (_jsx(Segmented, { label: m.flow, size: "sm", value: flow, onValueChange: openFlow, options: flows.map(([key, f]) => ({ value: key, label: f.label ?? key })) })) : null, onFlowRename && !readOnly ? (_jsxs(Button, { size: "sm", variant: "ghost", disabled: busy !== null, onClick: () => {
|
|
153
|
+
setFailure(null);
|
|
154
|
+
setDialog("rename-flow");
|
|
155
|
+
}, children: [_jsx(PencilIcon, { "aria-hidden": true }), m.renameFlow] })) : null, onFlowCreate && !readOnly ? (_jsxs(Button, { size: "sm", variant: "ghost", disabled: busy !== null, onClick: () => {
|
|
156
|
+
setFailure(null);
|
|
157
|
+
setDialog("new-flow");
|
|
158
|
+
}, children: [_jsx(PlusIcon, { "aria-hidden": true }), m.newFlow] })) : null] }), _jsx(WorkflowCanvas, { definition: definition, flow: flow, registry: registry, onChange: edit, readOnly: readOnly, selection: selection, onSelectionChange: select, problems: problems, className: "min-h-96 flex-1" }, flow)] }), _jsx("aside", { "aria-label": m.panels, className: "hidden w-88 shrink-0 flex-col @5xl:flex", children: side })] }), _jsx(Drawer, { open: drawer !== null, onOpenChange: (open) => !open && setDrawer(null), children: _jsxs(DrawerContent, { side: drawer === "palette" ? "left" : "right", className: "p-4", children: [_jsx(DrawerTitle, { children: drawer === "palette" ? messages.editor.palette : m.panels }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col", children: drawer === "palette" ? palette : side })] }) }), _jsx(Dialog, { open: dialog === "publish", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.publishTitle }), _jsx(DialogDescription, { children: published ? m.publishDescription(published.label) : m.firstPublish })] }), published ? (_jsx(WorkflowDiff, { diff: diffDefinitions(published.definition, definition), flows: definition.flows, label: m.draftAgainstLive, className: "max-h-80 overflow-y-auto" })) : null, blocking ? (_jsx("p", { role: "alert", className: "text-sm text-danger-fg", children: m.publishBlocked(blocking) })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => setDialog(null), children: messages.editor.cancel }), _jsx(Button, { type: "button", disabled: blocking > 0 || busy !== null, onClick: async () => {
|
|
138
159
|
if (await run("publish", () => onPublish?.(definition), m.publishedNotice))
|
|
139
160
|
setDialog(null);
|
|
140
161
|
}, children: busy === "publish" ? m.publishing : m.publish })] })] }) }), _jsx(Dialog, { open: dialog === "save-as", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.saveAsTitle }), _jsx(DialogDescription, { children: m.saveAsDescription })] }), _jsxs("form", { className: "flex flex-col gap-4", onSubmit: async (e) => {
|
|
@@ -147,8 +168,46 @@ function Workspace({ definition, onChange, registry, saved = null, published = n
|
|
|
147
168
|
}, children: [_jsx(Field, { label: messages.editor.name, required: true, children: _jsx(Input, { value: name, onChange: (e) => setName(e.target.value) }) }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => setDialog(null), children: messages.editor.cancel }), _jsx(Button, { type: "submit", disabled: !name.trim() || busy !== null, children: busy === "save-as" ? m.saving : m.saveAs })] })] })] }) }), _jsx(Dialog, { open: dialog === "compare", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.compareTitle }), _jsx(DialogDescription, { children: m.compareDescription })] }), saved ? (_jsx(WorkflowDiff, { diff: diffDefinitions(saved.definition, definition), flows: definition.flows, label: m.compareTitle, className: "max-h-80 overflow-y-auto" })) : null] }) }), _jsx(ConfirmDialog, { open: dialog === "discard", onOpenChange: (open) => !open && setDialog(null), title: m.discardTitle, description: m.discardDescription, confirmLabel: m.discard, destructive: true, onConfirm: () => {
|
|
148
169
|
if (base)
|
|
149
170
|
onChange?.(base.definition);
|
|
150
|
-
|
|
151
|
-
} }), _jsx(Dialog, { open:
|
|
171
|
+
select(null);
|
|
172
|
+
} }), _jsx(Dialog, { open: dialog === "new-flow" || dialog === "rename-flow", onOpenChange: (open) => !open && setDialog(null), children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: dialog === "rename-flow"
|
|
173
|
+
? m.renameFlowTitle(definition.flows[flow]?.label ?? flow)
|
|
174
|
+
: m.newFlowTitle }), _jsx(DialogDescription, { children: dialog === "rename-flow" ? m.renameFlowDescription : m.newFlowDescription })] }), dialog === "new-flow" || dialog === "rename-flow" ? (_jsx(FlowForm, { definition: definition, current: dialog === "rename-flow" ? flow : undefined, busy: busy !== null, failure: failure, onCancel: () => setDialog(null), onSubmit: async (name, label) => {
|
|
175
|
+
const renaming = dialog === "rename-flow";
|
|
176
|
+
const done = renaming
|
|
177
|
+
? await run("flow", () => onFlowRename?.(renameFlow(definition, flow, name, label), flow, name), m.flowRenamedNotice(name))
|
|
178
|
+
: await run("flow", () => onFlowCreate?.(addFlow(definition, name, label), name), m.flowCreatedNotice(name));
|
|
179
|
+
if (!done)
|
|
180
|
+
return;
|
|
181
|
+
setDialog(null);
|
|
182
|
+
if (renaming && selection?.flow === flow)
|
|
183
|
+
select({ ...selection, flow: name });
|
|
184
|
+
else if (!renaming)
|
|
185
|
+
select(null);
|
|
186
|
+
setFlowName(name);
|
|
187
|
+
} })) : null] }) }), _jsx(Dialog, { open: !!importPreview, onOpenChange: (open) => !open && onImportCancel?.(), children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: m.importTitle }), _jsx(DialogDescription, { children: m.importDescription })] }), importPreview ? (_jsx(WorkflowImportPreview, { preview: importPreview, busy: busy === "import", onCancel: onImportCancel, onConfirm: onImportConfirm
|
|
152
188
|
? () => void run("import", () => onImportConfirm(), m.importedNotice)
|
|
153
189
|
: undefined, className: "max-h-[70vh] overflow-y-auto" })) : null] }) })] }));
|
|
154
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* A flow's name and label, checked as they are typed: a new flow, or the
|
|
193
|
+
* open one (`current`) being renamed. Holds its own state so typing does not
|
|
194
|
+
* redraw the canvas.
|
|
195
|
+
*/
|
|
196
|
+
function FlowForm({ definition, current, busy, failure, onCancel, onSubmit, }) {
|
|
197
|
+
const { messages } = useUI();
|
|
198
|
+
const m = messages.workspace;
|
|
199
|
+
const currentLabel = current ? (definition.flows[current]?.label ?? "") : "";
|
|
200
|
+
const [name, setName] = React.useState(current ?? "");
|
|
201
|
+
const [label, setLabel] = React.useState(currentLabel);
|
|
202
|
+
const [touched, setTouched] = React.useState(false);
|
|
203
|
+
const trimmed = name.trim();
|
|
204
|
+
const problem = flowNameProblem(definition, trimmed, current);
|
|
205
|
+
const full = !current && Object.keys(definition.flows).length >= WORKFLOW_MAX_FLOWS;
|
|
206
|
+
const unchanged = !!current && trimmed === current && label.trim() === currentLabel;
|
|
207
|
+
return (_jsxs("form", { className: "flex flex-col gap-4", noValidate: true, onSubmit: (e) => {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
setTouched(true);
|
|
210
|
+
if (!problem && !full && !unchanged)
|
|
211
|
+
onSubmit(trimmed, label.trim());
|
|
212
|
+
}, children: [_jsx(Field, { label: m.flowName, hint: m.flowNameHint, error: touched && problem ? m.flowNameProblem(problem) : undefined, required: true, children: _jsx(Input, { value: name, autoComplete: "off", spellCheck: false, onChange: (e) => setName(e.target.value), onBlur: () => setTouched(true) }) }), _jsx(Field, { label: m.flowLabel, hint: m.flowLabelHint, children: _jsx(Input, { value: label, onChange: (e) => setLabel(e.target.value) }) }), full ? (_jsx("p", { role: "alert", className: "text-sm text-danger-fg", children: m.flowsFull(WORKFLOW_MAX_FLOWS) })) : null, failure ? (_jsx("p", { role: "alert", className: "text-sm text-danger-fg", children: failure })) : null, _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "outline", onClick: onCancel, children: messages.editor.cancel }), _jsx(Button, { type: "submit", disabled: full || unchanged || busy, children: busy ? m.saving : current ? m.rename : m.createFlow })] })] }));
|
|
213
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@calliopelabs/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Calliope AI shared operator UI: app shell, navigation, page frame, data table and list patterns, status colours, design tokens and appearance for Astrolift AI and Zentinelle AI.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"calliope-ai",
|