@nanobpm/bojtos-react 0.4.0 → 0.6.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 +32 -2
- package/dist/BpmnRuntimeView.d.ts +12 -1
- package/dist/BpmnRuntimeView.js +20 -4
- package/dist/TraceTimeline.d.ts +50 -0
- package/dist/TraceTimeline.js +81 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +5 -1
- package/dist/runState.d.ts +47 -0
- package/dist/runState.js +64 -0
- package/dist/useBojtos.d.ts +71 -4
- package/dist/useBojtos.js +51 -9
- package/package.json +5 -3
- package/src/BpmnRuntimeView.tsx +51 -4
- package/src/TraceTimeline.tsx +303 -0
- package/src/index.ts +30 -0
- package/src/runState.ts +72 -0
- package/src/useBojtos.ts +215 -11
package/src/BpmnRuntimeView.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
|
|
3
|
+
import { describeRunState, markerKey } from "./runState.js";
|
|
3
4
|
|
|
4
5
|
interface Canvas {
|
|
5
6
|
zoom(mode: string): void;
|
|
@@ -27,6 +28,17 @@ export interface BpmnRuntimeViewProps {
|
|
|
27
28
|
incidentIds: string[];
|
|
28
29
|
/** Optional class for the container element (it always fills its parent). */
|
|
29
30
|
className?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Accessible name for the diagram. The token and incident highlights are
|
|
33
|
+
* purely visual, so without this a screen-reader user is told nothing at all
|
|
34
|
+
* about what is running.
|
|
35
|
+
*/
|
|
36
|
+
label?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Map an element id to a human name for the live status announcement — pass
|
|
39
|
+
* the diagram's element names if you have them. Defaults to the raw id.
|
|
40
|
+
*/
|
|
41
|
+
elementName?: (elementId: string) => string;
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
/**
|
|
@@ -46,6 +58,8 @@ export function BpmnRuntimeView({
|
|
|
46
58
|
activeIds,
|
|
47
59
|
incidentIds,
|
|
48
60
|
className,
|
|
61
|
+
label = "BPMN process diagram",
|
|
62
|
+
elementName,
|
|
49
63
|
}: BpmnRuntimeViewProps) {
|
|
50
64
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
51
65
|
const viewerRef = useRef<NavigatedViewer | null>(null);
|
|
@@ -133,16 +147,49 @@ export function BpmnRuntimeView({
|
|
|
133
147
|
tokenOverlaysRef.current = nextOverlays;
|
|
134
148
|
}
|
|
135
149
|
|
|
150
|
+
// `activeIds` / `incidentIds` are almost always fresh arrays (`snapshot?.x ??
|
|
151
|
+
// []`), so depending on their identity re-painted every marker and re-created
|
|
152
|
+
// every token overlay on every render of the parent — visible churn on a busy
|
|
153
|
+
// diagram. Depend on the ids themselves instead.
|
|
154
|
+
const key = markerKey(activeIds, incidentIds);
|
|
136
155
|
useEffect(() => {
|
|
137
156
|
applyMarkers();
|
|
138
157
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
-
}, [
|
|
158
|
+
}, [key]);
|
|
140
159
|
|
|
141
160
|
return (
|
|
142
161
|
<div
|
|
143
|
-
ref={containerRef}
|
|
144
162
|
className={className}
|
|
145
|
-
style={{ width: "100%", height: "100%" }}
|
|
146
|
-
|
|
163
|
+
style={{ width: "100%", height: "100%", position: "relative" }}
|
|
164
|
+
>
|
|
165
|
+
<div
|
|
166
|
+
ref={containerRef}
|
|
167
|
+
role="img"
|
|
168
|
+
aria-label={label}
|
|
169
|
+
style={{ width: "100%", height: "100%" }}
|
|
170
|
+
/>
|
|
171
|
+
{/* The highlights are visual only. Mirror them as text, politely
|
|
172
|
+
announced, so the run is followable without seeing the diagram. No
|
|
173
|
+
`aria-label` here: on a live region it would override the changing
|
|
174
|
+
text in the accessible-name computation, so screen readers would
|
|
175
|
+
announce the static label instead of the run state. */}
|
|
176
|
+
<div
|
|
177
|
+
role="status"
|
|
178
|
+
aria-live="polite"
|
|
179
|
+
style={{
|
|
180
|
+
position: "absolute",
|
|
181
|
+
width: 1,
|
|
182
|
+
height: 1,
|
|
183
|
+
margin: -1,
|
|
184
|
+
padding: 0,
|
|
185
|
+
overflow: "hidden",
|
|
186
|
+
clip: "rect(0 0 0 0)",
|
|
187
|
+
whiteSpace: "nowrap",
|
|
188
|
+
border: 0,
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
{describeRunState(activeIds, incidentIds, elementName)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
147
194
|
);
|
|
148
195
|
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import {
|
|
3
|
+
buildTraceItems,
|
|
4
|
+
isTraceTurnGroup,
|
|
5
|
+
type ElementStatDto,
|
|
6
|
+
type IncidentDto,
|
|
7
|
+
type TraceItem,
|
|
8
|
+
type TraceRow,
|
|
9
|
+
type TraceTurnGroup,
|
|
10
|
+
} from "@nanobpm/bojtos-kit";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The shared activity log — the run told as a story rather than a flat stack of
|
|
14
|
+
* lines. It is the single component that retired the two drifted, forked
|
|
15
|
+
* `TraceTimeline` copies (nanobpm/bojtos#9): the web-demo framework's agent/tool/
|
|
16
|
+
* turn view and the console test-view's engine-event fold.
|
|
17
|
+
*
|
|
18
|
+
* It renders the framework-agnostic {@link TraceRow} model from
|
|
19
|
+
* `@nanobpm/bojtos-kit` — feed it whichever adapter matches your source:
|
|
20
|
+
*
|
|
21
|
+
* - `foldEngineEvents(useBojtos().events)` for the non-agentic / test-view case, or
|
|
22
|
+
* - `traceEntriesToRows(entries)` for handler-emitted agent/tool/turn entries.
|
|
23
|
+
*
|
|
24
|
+
* Consecutive rows sharing a `turn` fold into one card (the model's raw LLM reply,
|
|
25
|
+
* each tool it activated with its arguments, and — once it lands — what that tool
|
|
26
|
+
* returned); rows with no `turn` render as plain lines in order, so a non-agentic
|
|
27
|
+
* run looks exactly like the flat log it replaces.
|
|
28
|
+
*
|
|
29
|
+
* This module deliberately imports **only** the kit and React — never
|
|
30
|
+
* `./BpmnRuntimeView` or `bpmn-js` — so a trace-only import tree-shakes the
|
|
31
|
+
* diagram renderer out (the package is `sideEffects: false`). See
|
|
32
|
+
* `test/trace.timeline.test.ts`, which walks the built module graph to pin that.
|
|
33
|
+
*
|
|
34
|
+
* The markup keeps the class names the forked copies' CSS already targets
|
|
35
|
+
* (`timeline`, `timeline-turn`, `timeline-tool`, `log-line log-<kind>`, …) so a
|
|
36
|
+
* consumer's existing stylesheet applies unchanged; no design-system dependency
|
|
37
|
+
* is pulled in.
|
|
38
|
+
*/
|
|
39
|
+
export interface TraceTimelineProps {
|
|
40
|
+
/**
|
|
41
|
+
* The normalized rows to render. Produce them with a kit adapter
|
|
42
|
+
* (`foldEngineEvents` / `traceEntriesToRows`) or your own {@link TraceRow[]}.
|
|
43
|
+
*/
|
|
44
|
+
rows: TraceRow[];
|
|
45
|
+
/** `snapshot.elementStats` — per-element completion/active counts, engine-side. */
|
|
46
|
+
elementStats?: ElementStatDto[];
|
|
47
|
+
/** Incidents on the current snapshot, with their reason. */
|
|
48
|
+
incidents?: IncidentDto[];
|
|
49
|
+
/** BPMN element id → human label, for both the timeline and the panels below. */
|
|
50
|
+
labelFor?: (elementId: string) => string;
|
|
51
|
+
/** Card heading. Defaults to "Activity". */
|
|
52
|
+
title?: string;
|
|
53
|
+
/** Sub-heading under the title. */
|
|
54
|
+
description?: string;
|
|
55
|
+
/** Shown when there are no rows yet. Defaults to "Press Run to start.". */
|
|
56
|
+
emptyText?: string;
|
|
57
|
+
/** Optional class for the outer container. */
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeStringify(value: unknown, space?: number): string {
|
|
62
|
+
// `JSON.stringify(undefined)` returns the JS value `undefined` (not a string),
|
|
63
|
+
// which would render as nothing. Emit the literal "undefined" so a handler/tool
|
|
64
|
+
// that actually returned `undefined` is shown explicitly rather than vanishing.
|
|
65
|
+
if (value === undefined) return "undefined";
|
|
66
|
+
try {
|
|
67
|
+
// `JSON.stringify` throws on BigInt and circular structures; a replacer
|
|
68
|
+
// renders BigInt losslessly as its decimal string so trace payloads that
|
|
69
|
+
// carry engine-native BigInts don't crash serialization.
|
|
70
|
+
return JSON.stringify(
|
|
71
|
+
value,
|
|
72
|
+
(_key, val) => (typeof val === "bigint" ? val.toString() : val),
|
|
73
|
+
space,
|
|
74
|
+
);
|
|
75
|
+
} catch {
|
|
76
|
+
return "[unserializable value]";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function ToolStep({
|
|
81
|
+
activation,
|
|
82
|
+
result,
|
|
83
|
+
labelFor,
|
|
84
|
+
}: {
|
|
85
|
+
activation: TraceRow;
|
|
86
|
+
result: TraceRow | undefined;
|
|
87
|
+
labelFor: (elementId: string) => string;
|
|
88
|
+
}) {
|
|
89
|
+
const elementId = activation.elementId ?? "";
|
|
90
|
+
return (
|
|
91
|
+
<div className="timeline-tool">
|
|
92
|
+
<div className="timeline-tool-head">
|
|
93
|
+
<span className="timeline-badge timeline-badge-info">tool</span>
|
|
94
|
+
<strong>{labelFor(elementId) || elementId}</strong>
|
|
95
|
+
<code>{elementId}</code>
|
|
96
|
+
</div>
|
|
97
|
+
{activation.args !== undefined &&
|
|
98
|
+
Object.keys(activation.args).length > 0 && (
|
|
99
|
+
<div className="timeline-kv">
|
|
100
|
+
<span className="timeline-kv-label">arguments</span>
|
|
101
|
+
<code>{safeStringify(activation.args)}</code>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
<div className="timeline-kv">
|
|
105
|
+
<span className="timeline-kv-label">returned</span>
|
|
106
|
+
<code>
|
|
107
|
+
{result
|
|
108
|
+
? safeStringify(result.result)
|
|
109
|
+
: "— waiting for the job to complete —"}
|
|
110
|
+
</code>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function TurnCard({
|
|
117
|
+
group,
|
|
118
|
+
labelFor,
|
|
119
|
+
}: {
|
|
120
|
+
group: TraceTurnGroup;
|
|
121
|
+
labelFor: (elementId: string) => string;
|
|
122
|
+
}) {
|
|
123
|
+
const reply = group.rows.find((e) => e.kind === "llm");
|
|
124
|
+
const activations = group.rows.filter((e) => e.kind === "agent" && e.elementId);
|
|
125
|
+
const results = group.rows.filter((e) => e.kind === "vars" && e.elementId);
|
|
126
|
+
const decisions = group.rows.filter((e) => e.kind === "agent" && !e.elementId);
|
|
127
|
+
const errors = group.rows.filter((e) => e.kind === "error");
|
|
128
|
+
// Entries a handler's own trace call emits (kind "tool") and any "vars" result
|
|
129
|
+
// that never paired with an activation above would otherwise vanish once
|
|
130
|
+
// stamped with a turn — render them as plain lines within the card, in order.
|
|
131
|
+
const activatedElementIds = new Set(activations.map((a) => a.elementId));
|
|
132
|
+
const loose = group.rows
|
|
133
|
+
.filter(
|
|
134
|
+
(e) =>
|
|
135
|
+
e.kind === "tool" ||
|
|
136
|
+
(e.kind === "vars" &&
|
|
137
|
+
e.elementId &&
|
|
138
|
+
!activatedElementIds.has(e.elementId)),
|
|
139
|
+
)
|
|
140
|
+
.sort((a, b) => a.id - b.id);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="timeline-turn">
|
|
144
|
+
<div className="timeline-turn-head">
|
|
145
|
+
<span
|
|
146
|
+
className={`timeline-badge ${
|
|
147
|
+
reply?.pending ? "timeline-badge-warning" : "timeline-badge-neutral"
|
|
148
|
+
}`}
|
|
149
|
+
>
|
|
150
|
+
Turn {group.turn}
|
|
151
|
+
</span>
|
|
152
|
+
{reply?.pending && <span className="timeline-pending">thinking…</span>}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{reply && <blockquote className="timeline-reply">{reply.text}</blockquote>}
|
|
156
|
+
|
|
157
|
+
{decisions.map((d) => (
|
|
158
|
+
<div key={d.key ?? d.id} className="timeline-note">
|
|
159
|
+
{d.text}
|
|
160
|
+
</div>
|
|
161
|
+
))}
|
|
162
|
+
|
|
163
|
+
{activations.map((a) => (
|
|
164
|
+
<ToolStep
|
|
165
|
+
key={a.key ?? a.id}
|
|
166
|
+
activation={a}
|
|
167
|
+
result={results.find((r) => r.elementId === a.elementId)}
|
|
168
|
+
labelFor={labelFor}
|
|
169
|
+
/>
|
|
170
|
+
))}
|
|
171
|
+
|
|
172
|
+
{loose.map((e) => (
|
|
173
|
+
<div key={e.key ?? e.id} className={`log-line log-${e.kind}`}>
|
|
174
|
+
{e.pending ? "⏳ " : ""}
|
|
175
|
+
{e.text}
|
|
176
|
+
</div>
|
|
177
|
+
))}
|
|
178
|
+
|
|
179
|
+
{errors.map((e) => (
|
|
180
|
+
<div key={e.id} className="timeline-error">
|
|
181
|
+
⚠ {e.text}
|
|
182
|
+
</div>
|
|
183
|
+
))}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function TraceTimeline({
|
|
189
|
+
rows,
|
|
190
|
+
elementStats = [],
|
|
191
|
+
incidents = [],
|
|
192
|
+
labelFor = (id) => id,
|
|
193
|
+
title = "Activity",
|
|
194
|
+
description = "Agent turns, model replies, and tool calls — read top to bottom as a story.",
|
|
195
|
+
emptyText = "Press Run to start.",
|
|
196
|
+
className,
|
|
197
|
+
}: TraceTimelineProps) {
|
|
198
|
+
const items: TraceItem[] = useMemo(() => buildTraceItems(rows), [rows]);
|
|
199
|
+
const [copied, setCopied] = useState(false);
|
|
200
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
201
|
+
|
|
202
|
+
// Keep the newest step in view as the run grows, same as the flat logs this
|
|
203
|
+
// replaces.
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const el = scrollRef.current;
|
|
206
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
207
|
+
}, [items]);
|
|
208
|
+
|
|
209
|
+
const copyJson = () => {
|
|
210
|
+
const payload = {
|
|
211
|
+
log: rows.map(({ id: _id, ...rest }) => rest),
|
|
212
|
+
elementStats,
|
|
213
|
+
incidents,
|
|
214
|
+
};
|
|
215
|
+
const text = safeStringify(payload, 2);
|
|
216
|
+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
|
217
|
+
navigator.clipboard
|
|
218
|
+
.writeText(text)
|
|
219
|
+
.then(() => {
|
|
220
|
+
setCopied(true);
|
|
221
|
+
setTimeout(() => setCopied(false), 1500);
|
|
222
|
+
})
|
|
223
|
+
.catch(() => {
|
|
224
|
+
// Clipboard access can be denied (permissions policy, insecure
|
|
225
|
+
// context, an embed iframe without a clipboard-write allowance) —
|
|
226
|
+
// fail quietly rather than surfacing an error for a convenience
|
|
227
|
+
// action, not a run-blocking one.
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div className={className ? `timeline-panel ${className}` : "timeline-panel"}>
|
|
234
|
+
<div className="timeline-header">
|
|
235
|
+
<div className="timeline-title">{title}</div>
|
|
236
|
+
{description && (
|
|
237
|
+
<div className="timeline-description">{description}</div>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="timeline-toolbar">
|
|
242
|
+
<button type="button" onClick={copyJson}>
|
|
243
|
+
{copied ? "Copied!" : "Copy run as JSON"}
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div className="timeline" ref={scrollRef}>
|
|
248
|
+
{items.length === 0 ? (
|
|
249
|
+
<div className="log-empty">{emptyText}</div>
|
|
250
|
+
) : (
|
|
251
|
+
items.map((item) =>
|
|
252
|
+
isTraceTurnGroup(item) ? (
|
|
253
|
+
<TurnCard
|
|
254
|
+
key={`turn-${item.turn}-${item.rows[0].key ?? item.rows[0].id}`}
|
|
255
|
+
group={item}
|
|
256
|
+
labelFor={labelFor}
|
|
257
|
+
/>
|
|
258
|
+
) : (
|
|
259
|
+
<div key={item.key ?? item.id} className={`log-line log-${item.kind}`}>
|
|
260
|
+
{item.pending ? "⏳ " : ""}
|
|
261
|
+
{item.text}
|
|
262
|
+
</div>
|
|
263
|
+
),
|
|
264
|
+
)
|
|
265
|
+
)}
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
{(elementStats.length > 0 || incidents.length > 0) && (
|
|
269
|
+
<div className="timeline-engine-view">
|
|
270
|
+
{elementStats.length > 0 && (
|
|
271
|
+
<div className="timeline-stats">
|
|
272
|
+
<span className="timeline-kv-label">Element completion</span>
|
|
273
|
+
<ul>
|
|
274
|
+
{elementStats
|
|
275
|
+
.filter((s) => s.completed > 0 || (s.active ?? 0) > 0)
|
|
276
|
+
.map((s) => (
|
|
277
|
+
<li key={s.elementId}>
|
|
278
|
+
<code>{labelFor(s.elementId) || s.elementId}</code>{" "}
|
|
279
|
+
completed {s.completed}
|
|
280
|
+
{s.active ? `, ${s.active} active` : ""}
|
|
281
|
+
</li>
|
|
282
|
+
))}
|
|
283
|
+
</ul>
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
{incidents.length > 0 && (
|
|
287
|
+
<div className="timeline-incidents">
|
|
288
|
+
<span className="timeline-kv-label">Incidents</span>
|
|
289
|
+
<ul>
|
|
290
|
+
{incidents.map((inc, i) => (
|
|
291
|
+
<li key={`${inc.elementId}-${i}`}>
|
|
292
|
+
<code>{labelFor(inc.elementId) || inc.elementId}</code> —{" "}
|
|
293
|
+
{inc.reason}
|
|
294
|
+
</li>
|
|
295
|
+
))}
|
|
296
|
+
</ul>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,14 +20,31 @@ export {
|
|
|
20
20
|
BpmnRuntimeView,
|
|
21
21
|
type BpmnRuntimeViewProps,
|
|
22
22
|
} from "./BpmnRuntimeView.js";
|
|
23
|
+
// The shared activity log (#9). Trace-only imports tree-shake bpmn-js out —
|
|
24
|
+
// TraceTimeline imports only the kit + React, never BpmnRuntimeView.
|
|
25
|
+
export { TraceTimeline, type TraceTimelineProps } from "./TraceTimeline.js";
|
|
26
|
+
export {
|
|
27
|
+
describeRunState,
|
|
28
|
+
markerKey,
|
|
29
|
+
bpmnKey,
|
|
30
|
+
resourceList,
|
|
31
|
+
capEvents,
|
|
32
|
+
} from "./runState.js";
|
|
23
33
|
export {
|
|
24
34
|
JobFailure,
|
|
35
|
+
settleReason,
|
|
36
|
+
unhandledJobTypes,
|
|
37
|
+
buildTraceItems,
|
|
38
|
+
isTraceTurnGroup,
|
|
39
|
+
foldEngineEvents,
|
|
40
|
+
traceEntriesToRows,
|
|
25
41
|
type JobHandler,
|
|
26
42
|
type JobResult,
|
|
27
43
|
type AgentHandler,
|
|
28
44
|
type DispatchOptions,
|
|
29
45
|
type DispatchResult,
|
|
30
46
|
type RoundResult,
|
|
47
|
+
type SettleReason,
|
|
31
48
|
} from "@nanobpm/bojtos-kit";
|
|
32
49
|
export type {
|
|
33
50
|
BojtosSession,
|
|
@@ -37,8 +54,21 @@ export type {
|
|
|
37
54
|
ActivatedJob,
|
|
38
55
|
IncidentDto,
|
|
39
56
|
TimerDto,
|
|
57
|
+
UserTaskDto,
|
|
58
|
+
MessageSubscriptionDto,
|
|
59
|
+
SignalSubscriptionDto,
|
|
60
|
+
ElementStatDto,
|
|
61
|
+
SequenceFlowDto,
|
|
62
|
+
DecisionInstanceDto,
|
|
40
63
|
ActiveEl,
|
|
64
|
+
ActivateInstruction,
|
|
41
65
|
AgentActivation,
|
|
42
66
|
AgentResult,
|
|
43
67
|
WasmEvent,
|
|
68
|
+
TraceRowKind,
|
|
69
|
+
TraceEntry,
|
|
70
|
+
TraceRow,
|
|
71
|
+
TraceTurnGroup,
|
|
72
|
+
TraceItem,
|
|
73
|
+
TraceAdapter,
|
|
44
74
|
} from "@nanobpm/bojtos-kit";
|
package/src/runState.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers behind the React binding's decisions.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the component and the hook — and out of any module that imports
|
|
5
|
+
* bpmn-js — so they can be tested without a DOM or the peer dependency. The
|
|
6
|
+
* component and hook hold rendering and lifecycle; what counts as a change, how
|
|
7
|
+
* a run reads aloud, which resources to deploy and how far to trim the log are
|
|
8
|
+
* decisions, and decisions are worth testing.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Stable key for a marker set, so unchanged ids don't re-paint the diagram.
|
|
13
|
+
*
|
|
14
|
+
* Relies on BPMN element ids being XML NCNames (so they can't contain `,` or
|
|
15
|
+
* `|`) — that invariant is what keeps the two-field join collision-free. If ids
|
|
16
|
+
* could contain the delimiters, `["a,b"],[]` and `["a"],["b"]`-style pairs
|
|
17
|
+
* would key alike.
|
|
18
|
+
*/
|
|
19
|
+
export function markerKey(activeIds: string[], incidentIds: string[]): string {
|
|
20
|
+
return `${activeIds.join(",")}|${incidentIds.join(",")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Content key for the `bpmn` prop, so a fresh array identity each render doesn't
|
|
25
|
+
* re-create the engine but a real content change still does. Only the array
|
|
26
|
+
* case is serialized (with a boundary-preserving `JSON.stringify`, not a
|
|
27
|
+
* `join` — a delimiter join lets two different resource arrays collapse to one
|
|
28
|
+
* key when a resource borders/contains the delimiter, silently missing a real
|
|
29
|
+
* change). A lone string can't have array-boundary collisions and React deps
|
|
30
|
+
* already compare strings by value, so it passes through untouched — no needless
|
|
31
|
+
* re-walk of potentially large BPMN XML each render.
|
|
32
|
+
*/
|
|
33
|
+
export function bpmnKey(bpmn: string | string[]): string {
|
|
34
|
+
return Array.isArray(bpmn) ? JSON.stringify(bpmn) : bpmn;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize the `bpmn` prop to an ordered resource list for deployment. Deploy
|
|
39
|
+
* order is significant: a later resource can reference an earlier one (a call
|
|
40
|
+
* activity's child), so the array order is preserved verbatim.
|
|
41
|
+
*/
|
|
42
|
+
export function resourceList(bpmn: string | string[]): string[] {
|
|
43
|
+
return Array.isArray(bpmn) ? bpmn : [bpmn];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Trim an event log to the consumer's cap, keeping the most recent events.
|
|
48
|
+
* `undefined` or a negative cap means "no cap"; `0` means "keep nothing". The
|
|
49
|
+
* input array is never mutated.
|
|
50
|
+
*/
|
|
51
|
+
export function capEvents<T>(all: T[], cap: number | undefined): T[] {
|
|
52
|
+
return cap !== undefined && cap >= 0 && all.length > cap
|
|
53
|
+
? all.slice(all.length - cap)
|
|
54
|
+
: all;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A one-line description of run state, for the diagram's live region. The token
|
|
59
|
+
* and incident highlights are purely visual; this is the same information as
|
|
60
|
+
* text.
|
|
61
|
+
*/
|
|
62
|
+
export function describeRunState(
|
|
63
|
+
activeIds: string[],
|
|
64
|
+
incidentIds: string[],
|
|
65
|
+
name: (id: string) => string = (id) => id,
|
|
66
|
+
): string {
|
|
67
|
+
const parts: string[] = [];
|
|
68
|
+
if (activeIds.length) parts.push(`Running: ${activeIds.map(name).join(", ")}`);
|
|
69
|
+
if (incidentIds.length)
|
|
70
|
+
parts.push(`Incident: ${incidentIds.map(name).join(", ")}`);
|
|
71
|
+
return parts.length ? parts.join(". ") : "Nothing running";
|
|
72
|
+
}
|