flexdesk 0.4.7 → 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 +1036 -301
- package/css/base.css +2958 -2
- package/css/flexdesk.css +2962 -4
- package/css/overrides.css +4 -2
- package/css/tokens.css +86 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +782 -782
- package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
- package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
- package/dist/{chunk-ELXVW542.js → chunk-6NG7YVBG.js} +3 -471
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/{chunk-F5FYDCOA.js → chunk-CNXP3SIN.js} +2373 -2373
- package/dist/{chunk-F5FYDCOA.js.map → chunk-CNXP3SIN.js.map} +7 -7
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-WMGOJ3I6.js → chunk-KH2GJTKR.js} +3702 -2628
- package/dist/chunk-KH2GJTKR.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/flexdesk.css +2962 -4
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +2195 -2195
- package/dist/tokens.css +86 -0
- package/dist/widgets.js +5824 -5796
- package/dist/widgets.js.map +1 -1
- package/dist/wm.js +8077 -8074
- package/dist/wm.js.map +7 -7
- package/package.json +89 -81
- package/src/canvas/canvas_adapter.js +523 -0
- package/src/canvas/edge_router.js +392 -0
- package/src/canvas/graph/connector_router.js +189 -0
- package/src/canvas/graph/history_service.js +170 -0
- package/src/canvas/graph/node_platform.js +116 -0
- package/src/canvas/index.js +16 -0
- package/src/flow/canvas_editor/editor.js +1081 -0
- package/src/flow/canvas_editor/index.js +16 -0
- package/src/flow/canvas_editor/layout.js +118 -0
- package/src/flow/canvas_editor/strings.js +37 -0
- package/src/flow/kit/always_before.js +325 -0
- package/src/flow/kit/catalogue.js +170 -0
- package/src/flow/kit/chip_input.js +461 -0
- package/src/flow/kit/dom.js +205 -0
- package/src/flow/kit/findings.js +129 -0
- package/src/flow/kit/history.js +173 -0
- package/src/flow/kit/index.js +38 -0
- package/src/flow/kit/keys.js +127 -0
- package/src/flow/kit/logic_graph.js +210 -0
- package/src/flow/kit/references.js +199 -0
- package/src/flow/kit/settings_panel.js +357 -0
- package/src/flow/kit/settings_schema.js +175 -0
- package/src/flow/kit/step_picker.js +209 -0
- package/src/flow/kit/strings.js +102 -0
- package/src/flow/kit/value_picker.js +238 -0
- package/src/flow/kit/widgets.js +763 -0
- package/src/flow/lanes/editor.js +1469 -0
- package/src/flow/lanes/index.js +26 -0
- package/src/flow/lanes/layout.js +525 -0
- package/src/flow/lanes/pipeline.js +327 -0
- package/src/flow/lanes/preview.js +109 -0
- package/src/flow/lanes/strings.js +98 -0
- package/src/flow/outline/editor.js +1511 -0
- package/src/flow/outline/index.js +20 -0
- package/src/flow/outline/mapping.js +286 -0
- package/src/flow/outline/menu.js +154 -0
- package/src/flow/outline/operations.js +738 -0
- package/src/flow/outline/recognise.js +755 -0
- package/src/flow/outline/strings.js +135 -0
- package/src/tiling/landing_table.js +13 -5
- package/src/tiling/wm.js +3916 -3916
- package/src/ui/components/data_table.js +4205 -3294
- package/src/ui/components/row_activation.js +70 -0
- package/src/ui/components/table_values.js +423 -0
- package/dist/chunk-ELXVW542.js.map +0 -7
- package/dist/chunk-WMGOJ3I6.js.map +0 -7
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A DATA FLOW'S JSON, AND EVERY EDIT THE LANE EDITOR MAKES TO IT (36 §7.1).
|
|
3
|
+
* PURE: no DOM; runs under plain node.
|
|
4
|
+
*
|
|
5
|
+
* { nodes: [{ id, type, label?, config, position? }],
|
|
6
|
+
* connections: [{ id, sourceId, sourcePort, targetId, targetPort }],
|
|
7
|
+
* parameters: { name: { type, default?, description? } } }
|
|
8
|
+
*
|
|
9
|
+
* Two rules on top of the shape, each for a reason:
|
|
10
|
+
*
|
|
11
|
+
* - **A step's name is a top-level `label`**, never a key inside `config`:
|
|
12
|
+
* a consumer may hash the config (a cache key), and renaming a step must
|
|
13
|
+
* not change what it computes.
|
|
14
|
+
* - **Connection ids are deterministic** — `c1`, `c2`, … the lowest free
|
|
15
|
+
* number — never a clock-derived id, so the same edits give the same bytes.
|
|
16
|
+
*
|
|
17
|
+
* A node's `position`, when present, is kept and never used; a new node has
|
|
18
|
+
* none. Keys this module does not know — on the flow, a step or a connection —
|
|
19
|
+
* are KEPT, never dropped: the editor is a view over the JSON, not a repair of
|
|
20
|
+
* it.
|
|
21
|
+
*
|
|
22
|
+
* `serialisePipeline` is byte-stable: known keys in a fixed order, `config`
|
|
23
|
+
* keys sorted (all the way down), any other key sorted after the known ones,
|
|
24
|
+
* steps, connections and parameters in their stored order. A flow opened and
|
|
25
|
+
* saved with no edit is the identical text.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { stepPorts } from './layout.js';
|
|
29
|
+
|
|
30
|
+
/** The edits the lane editor records — anything else is refused by its history. */
|
|
31
|
+
export const LANE_ACTIONS = Object.freeze([
|
|
32
|
+
'flow:step:add', 'flow:step:remove', 'flow:step:label', 'flow:step:config', 'flow:join:set',
|
|
33
|
+
'flow:source:add', 'flow:parameter:add', 'flow:parameter:change', 'flow:parameter:remove', 'flow:settings',
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** A parameter's name: what a reference to it can spell. */
|
|
37
|
+
export const PARAMETER_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
38
|
+
|
|
39
|
+
/** The step ids a server accepts: letters, digits, `-` and `_`, at most 64. */
|
|
40
|
+
const ID_OK = /^[A-Za-z0-9_-]{1,64}$/;
|
|
41
|
+
|
|
42
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
43
|
+
const clone = (v) => (v === undefined ? undefined : structuredClone(v));
|
|
44
|
+
|
|
45
|
+
const NODE_KEYS = ['id', 'type', 'label', 'config', 'position'];
|
|
46
|
+
const CONN_KEYS = ['id', 'sourceId', 'sourcePort', 'targetId', 'targetPort'];
|
|
47
|
+
const PARAM_KEYS = ['type', 'default', 'description'];
|
|
48
|
+
const TOP_KEYS = ['nodes', 'connections', 'parameters'];
|
|
49
|
+
|
|
50
|
+
export function emptyPipeline() {
|
|
51
|
+
return { nodes: [], connections: [], parameters: {} };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A deep copy with every field the flow carries, defaults filled in. */
|
|
55
|
+
export function normalisePipeline(pipeline) {
|
|
56
|
+
const p = isObject(pipeline) ? pipeline : emptyPipeline();
|
|
57
|
+
const out = {
|
|
58
|
+
nodes: (Array.isArray(p.nodes) ? p.nodes : []).filter(isObject).map((n) => {
|
|
59
|
+
const node = { id: String(n.id), type: String(n.type ?? '') };
|
|
60
|
+
if (n.label !== undefined && n.label !== null && String(n.label) !== '') node.label = String(n.label);
|
|
61
|
+
node.config = isObject(n.config) ? clone(n.config) : {};
|
|
62
|
+
if (n.position !== undefined && n.position !== null) node.position = clone(n.position);
|
|
63
|
+
for (const k of Object.keys(n)) if (!NODE_KEYS.includes(k)) node[k] = clone(n[k]);
|
|
64
|
+
return node;
|
|
65
|
+
}),
|
|
66
|
+
connections: (Array.isArray(p.connections) ? p.connections : []).filter(isObject).map((c) => {
|
|
67
|
+
const conn = {};
|
|
68
|
+
if (c.id !== undefined && c.id !== null) conn.id = String(c.id);
|
|
69
|
+
conn.sourceId = String(c.sourceId);
|
|
70
|
+
conn.sourcePort = String(c.sourcePort ?? 'out');
|
|
71
|
+
conn.targetId = String(c.targetId);
|
|
72
|
+
conn.targetPort = String(c.targetPort ?? 'in');
|
|
73
|
+
for (const k of Object.keys(c)) if (!CONN_KEYS.includes(k)) conn[k] = clone(c[k]);
|
|
74
|
+
return conn;
|
|
75
|
+
}),
|
|
76
|
+
parameters: isObject(p.parameters) ? clone(p.parameters) : {},
|
|
77
|
+
};
|
|
78
|
+
for (const k of Object.keys(p)) if (!TOP_KEYS.includes(k)) out[k] = clone(p[k]);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sortedKeys(value) {
|
|
83
|
+
if (Array.isArray(value)) return value.map(sortedKeys);
|
|
84
|
+
if (isObject(value)) return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortedKeys(value[k])]));
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Known keys first, in their order; every other key sorted after them. */
|
|
89
|
+
function ordered(obj, known, transform = {}) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const k of known) {
|
|
92
|
+
if (obj[k] === undefined) continue;
|
|
93
|
+
out[k] = transform[k] ? transform[k](obj[k]) : obj[k];
|
|
94
|
+
}
|
|
95
|
+
for (const k of Object.keys(obj).filter((x) => !known.includes(x)).sort()) out[k] = sortedKeys(obj[k]);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The text a save sends and a reload must reproduce exactly. */
|
|
100
|
+
export function serialisePipeline(pipeline) {
|
|
101
|
+
const p = normalisePipeline(pipeline);
|
|
102
|
+
const top = {
|
|
103
|
+
nodes: p.nodes.map((n) => ordered(n, NODE_KEYS, { config: sortedKeys, position: sortedKeys })),
|
|
104
|
+
connections: p.connections.map((c) => ordered(c, CONN_KEYS)),
|
|
105
|
+
parameters: Object.fromEntries(Object.entries(p.parameters)
|
|
106
|
+
.map(([k, v]) => [k, isObject(v) ? ordered(v, PARAM_KEYS, { default: sortedKeys }) : sortedKeys(v)])),
|
|
107
|
+
};
|
|
108
|
+
for (const k of Object.keys(p).filter((x) => !TOP_KEYS.includes(x)).sort()) top[k] = sortedKeys(p[k]);
|
|
109
|
+
return JSON.stringify(top);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The lowest free `cN`. */
|
|
113
|
+
export function nextConnectionId(pipeline) {
|
|
114
|
+
const taken = new Set((pipeline?.connections || []).map((c) => String(c.id)));
|
|
115
|
+
for (let i = 1; ; i += 1) if (!taken.has(`c${i}`)) return `c${i}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A new step id for `typeId`: the catalogue's stem, then `stem-2`, `stem-3`, … */
|
|
119
|
+
export function nextStepId(pipeline, typeId, catalogue = null) {
|
|
120
|
+
const base = catalogue?.idBase
|
|
121
|
+
? catalogue.idBase(typeId)
|
|
122
|
+
: (String(typeId || 'step').replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 40) || 'step');
|
|
123
|
+
const taken = new Set((pipeline?.nodes || []).map((n) => String(n.id)));
|
|
124
|
+
if (!taken.has(base)) return base;
|
|
125
|
+
for (let i = 2; ; i += 1) if (!taken.has(`${base}-${i}`)) return `${base}-${i}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const findNode = (p, id) => p.nodes.find((n) => n.id === id) ?? null;
|
|
129
|
+
|
|
130
|
+
/** A step's lane ports (`stepPorts` over this flow's connections). */
|
|
131
|
+
export function portsOf(pipeline, catalogue, id) {
|
|
132
|
+
const node = findNode(pipeline, id);
|
|
133
|
+
return node ? stepPorts(catalogue, node, pipeline.connections) : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The connection that feeds a step's lane input (the first, in connection order), or null. */
|
|
137
|
+
export function laneFeedOf(pipeline, catalogue, id) {
|
|
138
|
+
const ports = portsOf(pipeline, catalogue, id);
|
|
139
|
+
if (!ports || ports.laneInput === null) return null;
|
|
140
|
+
return pipeline.connections.find((c) => c.targetId === id && c.targetPort === ports.laneInput) ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The connection into `id` at `port`, or null. */
|
|
144
|
+
export function inputOf(pipeline, id, port) {
|
|
145
|
+
return pipeline.connections.find((c) => c.targetId === id && c.targetPort === port) ?? null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every step `id` leads to, by any line (not `id` itself, unless a cycle comes back to it). */
|
|
149
|
+
export function downstreamOf(pipeline, id) {
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
const todo = [id];
|
|
152
|
+
while (todo.length) {
|
|
153
|
+
const at = todo.shift();
|
|
154
|
+
for (const c of pipeline.connections) {
|
|
155
|
+
if (c.sourceId !== at || seen.has(c.targetId)) continue;
|
|
156
|
+
seen.add(c.targetId);
|
|
157
|
+
todo.push(c.targetId);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return seen;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Every step whose output reaches `id`, NEAREST FIRST (then in `nodes` order). */
|
|
164
|
+
export function upstreamOf(pipeline, id) {
|
|
165
|
+
const dist = new Map();
|
|
166
|
+
const todo = [[id, 0]];
|
|
167
|
+
while (todo.length) {
|
|
168
|
+
const [at, d] = todo.shift();
|
|
169
|
+
for (const c of pipeline.connections) {
|
|
170
|
+
if (c.targetId !== at || dist.has(c.sourceId) || c.sourceId === id) continue;
|
|
171
|
+
dist.set(c.sourceId, d + 1);
|
|
172
|
+
todo.push([c.sourceId, d + 1]);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const order = new Map(pipeline.nodes.map((n, i) => [n.id, i]));
|
|
176
|
+
return [...dist.keys()].sort((a, b) => (dist.get(a) - dist.get(b)) || ((order.get(a) ?? 0) - (order.get(b) ?? 0)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function newNode(pipeline, catalogue, typeId, { id = null, label = null, config = null } = {}) {
|
|
180
|
+
const nid = id && ID_OK.test(id) && !findNode(pipeline, id) ? id : nextStepId(pipeline, typeId, catalogue);
|
|
181
|
+
return { id: nid, type: typeId, ...(label ? { label: String(label) } : {}),
|
|
182
|
+
config: isObject(config) ? clone(config) : {} };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Add a step of `typeId` after `afterId`, SPLICED between it and what it led
|
|
187
|
+
* to (`A → new → B`): the line that went on in A's lane (else A's first line
|
|
188
|
+
* out of its continuation) now leaves the new step, and a new line joins A to
|
|
189
|
+
* the new step's lane input — put where the moved line was, so the new step
|
|
190
|
+
* goes on in A's lane rather than starting a fork. A step with no output (a
|
|
191
|
+
* sink) is not spliced: it is joined to A, beside what A already leads to.
|
|
192
|
+
* Returns the new node, or null when `afterId` is not a step.
|
|
193
|
+
*/
|
|
194
|
+
export function addStepAfter(pipeline, catalogue, afterId, typeId, opts = {}) {
|
|
195
|
+
const after = findNode(pipeline, afterId);
|
|
196
|
+
if (!after) return null;
|
|
197
|
+
const node = newNode(pipeline, catalogue, typeId, opts);
|
|
198
|
+
pipeline.nodes.push(node);
|
|
199
|
+
const a = stepPorts(catalogue, after, pipeline.connections);
|
|
200
|
+
const n = stepPorts(catalogue, node, pipeline.connections);
|
|
201
|
+
if (a.continuation === null || n.laneInput === null) return node;
|
|
202
|
+
const outs = pipeline.connections.filter((c) => c.sourceId === afterId && c.sourcePort === a.continuation);
|
|
203
|
+
const isLaneFeed = (c) => laneFeedOf(pipeline, catalogue, c.targetId) === c;
|
|
204
|
+
const splice = n.continuation === null ? null : (outs.find(isLaneFeed) ?? outs[0] ?? null);
|
|
205
|
+
const join = { id: nextConnectionId(pipeline), sourceId: afterId, sourcePort: a.continuation,
|
|
206
|
+
targetId: node.id, targetPort: n.laneInput };
|
|
207
|
+
if (splice) {
|
|
208
|
+
const at = pipeline.connections.indexOf(splice);
|
|
209
|
+
splice.sourceId = node.id;
|
|
210
|
+
splice.sourcePort = n.continuation;
|
|
211
|
+
pipeline.connections.splice(at, 0, join);
|
|
212
|
+
} else {
|
|
213
|
+
pipeline.connections.push(join);
|
|
214
|
+
}
|
|
215
|
+
return node;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Add a source: a new step at the end of `nodes`, which starts the last lane. */
|
|
219
|
+
export function addSource(pipeline, catalogue, typeId, opts = {}) {
|
|
220
|
+
const node = newNode(pipeline, catalogue, typeId, opts);
|
|
221
|
+
pipeline.nodes.push(node);
|
|
222
|
+
return node;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Remove a step. Its LANE INPUT is reconnected to everything it led to — each
|
|
227
|
+
* of its lines out keeps its id and its target, and now leaves the step that
|
|
228
|
+
* fed this one — and every other line into it goes. A step nothing fed (a
|
|
229
|
+
* source) leaves the steps after it unconnected, each the start of a lane
|
|
230
|
+
* flagged so. Returns whether anything was removed.
|
|
231
|
+
*/
|
|
232
|
+
export function removeStep(pipeline, catalogue, id) {
|
|
233
|
+
if (!findNode(pipeline, id)) return false;
|
|
234
|
+
const feed = laneFeedOf(pipeline, catalogue, id);
|
|
235
|
+
const kept = [];
|
|
236
|
+
for (const c of pipeline.connections) {
|
|
237
|
+
if (c.targetId === id) continue;
|
|
238
|
+
if (c.sourceId === id) {
|
|
239
|
+
if (!feed || feed.sourceId === c.targetId) continue;
|
|
240
|
+
const twin = kept.some((k) => k.sourceId === feed.sourceId && k.sourcePort === feed.sourcePort
|
|
241
|
+
&& k.targetId === c.targetId && k.targetPort === c.targetPort);
|
|
242
|
+
if (twin) continue;
|
|
243
|
+
c.sourceId = feed.sourceId;
|
|
244
|
+
c.sourcePort = feed.sourcePort;
|
|
245
|
+
}
|
|
246
|
+
kept.push(c);
|
|
247
|
+
}
|
|
248
|
+
pipeline.connections = kept;
|
|
249
|
+
pipeline.nodes = pipeline.nodes.filter((n) => n.id !== id);
|
|
250
|
+
return true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** May `sourceId` feed `stepId` without making a cycle? */
|
|
254
|
+
export function mayFeed(pipeline, stepId, sourceId) {
|
|
255
|
+
if (!sourceId || sourceId === stepId) return false;
|
|
256
|
+
return !downstreamOf(pipeline, stepId).has(sourceId);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Feed `stepId`'s input `port` from `sourceId`'s continuation, or (`null`)
|
|
261
|
+
* leave it unconnected. The line already there keeps its id; a new one takes
|
|
262
|
+
* the lowest free. Refuses (returns false) a source that would make a cycle.
|
|
263
|
+
*/
|
|
264
|
+
export function setInput(pipeline, catalogue, stepId, port, sourceId) {
|
|
265
|
+
if (!findNode(pipeline, stepId)) return false;
|
|
266
|
+
const existing = pipeline.connections.filter((c) => c.targetId === stepId && c.targetPort === port);
|
|
267
|
+
if (!sourceId) {
|
|
268
|
+
if (!existing.length) return false;
|
|
269
|
+
pipeline.connections = pipeline.connections.filter((c) => !existing.includes(c));
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
const source = findNode(pipeline, sourceId);
|
|
273
|
+
if (!source || !mayFeed(pipeline, stepId, sourceId)) return false;
|
|
274
|
+
const out = stepPorts(catalogue, source, pipeline.connections).continuation ?? 'out';
|
|
275
|
+
if (existing.length === 1 && existing[0].sourceId === sourceId && existing[0].sourcePort === out) return false;
|
|
276
|
+
if (existing.length) {
|
|
277
|
+
existing[0].sourceId = sourceId;
|
|
278
|
+
existing[0].sourcePort = out;
|
|
279
|
+
pipeline.connections = pipeline.connections.filter((c) => c === existing[0] || !existing.includes(c));
|
|
280
|
+
} else {
|
|
281
|
+
pipeline.connections.push({ id: nextConnectionId(pipeline), sourceId, sourcePort: out, targetId: stepId,
|
|
282
|
+
targetPort: port });
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The steps that may feed `stepId`'s input — every step with an output that
|
|
289
|
+
* would not make a cycle — in LANE order (lane by lane, left to right).
|
|
290
|
+
*/
|
|
291
|
+
export function inputCandidates(pipeline, catalogue, layout, stepId) {
|
|
292
|
+
const below = downstreamOf(pipeline, stepId);
|
|
293
|
+
const ok = (id) => id !== stepId && !below.has(id)
|
|
294
|
+
&& portsOf(pipeline, catalogue, id)?.continuation !== null;
|
|
295
|
+
const out = [];
|
|
296
|
+
for (const lane of layout?.lanes || []) {
|
|
297
|
+
for (const s of lane.steps) if (ok(s.id)) out.push({ id: s.id, lane: lane.index, column: s.column });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── parameters ───────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Set a parameter, keeping its place among the others; `rename` gives it a new
|
|
306
|
+
* name in the same place. `definition` is `{type, default?, description?}`.
|
|
307
|
+
*/
|
|
308
|
+
export function setParameter(pipeline, name, definition, { rename = null } = {}) {
|
|
309
|
+
const def = {};
|
|
310
|
+
if (definition?.type !== undefined) def.type = definition.type;
|
|
311
|
+
if (definition?.default !== undefined) def.default = clone(definition.default);
|
|
312
|
+
if (definition?.description !== undefined && definition.description !== '') def.description = definition.description;
|
|
313
|
+
const from = rename ?? name;
|
|
314
|
+
const entries = Object.entries(pipeline.parameters || {});
|
|
315
|
+
const at = entries.findIndex(([k]) => k === from);
|
|
316
|
+
if (at >= 0) entries.splice(at, 1, [name, def]);
|
|
317
|
+
else entries.push([name, def]);
|
|
318
|
+
pipeline.parameters = Object.fromEntries(entries.filter(([k], i) => k !== name || entries.findIndex(([x]) => x === k) === i));
|
|
319
|
+
return pipeline.parameters[name];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function removeParameter(pipeline, name) {
|
|
323
|
+
if (!pipeline.parameters || !(name in pipeline.parameters)) return false;
|
|
324
|
+
const { [name]: _gone, ...rest } = pipeline.parameters;
|
|
325
|
+
pipeline.parameters = rest;
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PREVIEW RUNNER — when the lane editor ASKS the consumer's providers, and
|
|
3
|
+
* which answer it believes (36 §7.7). It is the one timer an editor starts,
|
|
4
|
+
* and it only ever asks: a provider decides what a preview reads, how many
|
|
5
|
+
* rows, as whom, and that it writes nothing.
|
|
6
|
+
*
|
|
7
|
+
* - **Debounced.** `schedule()` (after a committed edit) waits `delayMs` —
|
|
8
|
+
* 800 by default — from the LAST edit, then sends ONE request for the
|
|
9
|
+
* whole flow to each provider (`preview`, `describe`) it has.
|
|
10
|
+
* - **The newest answer wins.** Every request carries a sequence number and
|
|
11
|
+
* an `AbortSignal`. A newer edit aborts the request in flight, and an
|
|
12
|
+
* answer that is not the newest is DROPPED even when it arrives — a
|
|
13
|
+
* provider that ignores its signal and answers late changes nothing.
|
|
14
|
+
* - **A failure is the provider's sentence**, handed to `onError`; the last
|
|
15
|
+
* answer that was believed stays where it is.
|
|
16
|
+
*
|
|
17
|
+
* PURE apart from the timer, which can be injected (`clock`) so a test can
|
|
18
|
+
* drive it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export function createPreviewRunner({ preview = null, describe = null, delayMs = 800, request,
|
|
22
|
+
onAnswer = null, onError = null, onState = null, onDropped = null,
|
|
23
|
+
clock = null } = {}) {
|
|
24
|
+
const timers = {
|
|
25
|
+
set: clock?.setTimeout ?? ((fn, ms) => setTimeout(fn, ms)),
|
|
26
|
+
clear: clock?.clearTimeout ?? ((h) => clearTimeout(h)),
|
|
27
|
+
};
|
|
28
|
+
const providers = [['preview', preview], ['describe', describe]].filter(([, fn]) => typeof fn === 'function');
|
|
29
|
+
let timer = null;
|
|
30
|
+
let seq = 0;
|
|
31
|
+
let live = null; // { seq, controller, pending: Set<kind> }
|
|
32
|
+
let destroyed = false;
|
|
33
|
+
|
|
34
|
+
const state = () => ({
|
|
35
|
+
enabled: providers.length > 0,
|
|
36
|
+
waiting: timer !== null,
|
|
37
|
+
busy: Boolean(live && live.pending.size > 0),
|
|
38
|
+
seq,
|
|
39
|
+
});
|
|
40
|
+
const told = () => onState?.(state());
|
|
41
|
+
|
|
42
|
+
function send() {
|
|
43
|
+
timer = null;
|
|
44
|
+
if (destroyed) return;
|
|
45
|
+
const mine = seq;
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
const pending = new Set(providers.map(([kind]) => kind));
|
|
48
|
+
live = { seq: mine, controller, pending };
|
|
49
|
+
const input = { ...(request?.() ?? {}), signal: controller.signal, seq: mine };
|
|
50
|
+
told();
|
|
51
|
+
for (const [kind, fn] of providers) {
|
|
52
|
+
let answer;
|
|
53
|
+
try {
|
|
54
|
+
answer = Promise.resolve(fn(input));
|
|
55
|
+
} catch (err) {
|
|
56
|
+
answer = Promise.reject(err);
|
|
57
|
+
}
|
|
58
|
+
const stale = () => destroyed || mine !== seq || controller.signal.aborted;
|
|
59
|
+
answer.then((value) => {
|
|
60
|
+
if (stale()) { onDropped?.({ kind, seq: mine }); return; }
|
|
61
|
+
pending.delete(kind);
|
|
62
|
+
onAnswer?.({ kind, answer: value, seq: mine });
|
|
63
|
+
told();
|
|
64
|
+
}, (error) => {
|
|
65
|
+
if (stale()) { onDropped?.({ kind, seq: mine }); return; }
|
|
66
|
+
pending.delete(kind);
|
|
67
|
+
onError?.({ kind, error, seq: mine });
|
|
68
|
+
told();
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
get enabled() { return providers.length > 0; },
|
|
75
|
+
get seq() { return seq; },
|
|
76
|
+
get state() { return state(); },
|
|
77
|
+
/** An edit: everything asked before it is stale; ask again `delayMs` after the last one. */
|
|
78
|
+
schedule({ immediate = false } = {}) {
|
|
79
|
+
if (destroyed || !providers.length) return;
|
|
80
|
+
seq += 1;
|
|
81
|
+
if (live) {
|
|
82
|
+
live.controller.abort();
|
|
83
|
+
live = null;
|
|
84
|
+
}
|
|
85
|
+
if (timer !== null) timers.clear(timer);
|
|
86
|
+
timer = timers.set(send, immediate ? 0 : delayMs);
|
|
87
|
+
told();
|
|
88
|
+
},
|
|
89
|
+
/** Ask now, without waiting for the debounce. */
|
|
90
|
+
now() {
|
|
91
|
+
if (destroyed || !providers.length) return;
|
|
92
|
+
seq += 1;
|
|
93
|
+
if (live) {
|
|
94
|
+
live.controller.abort();
|
|
95
|
+
live = null;
|
|
96
|
+
}
|
|
97
|
+
if (timer !== null) timers.clear(timer);
|
|
98
|
+
timer = null;
|
|
99
|
+
send();
|
|
100
|
+
},
|
|
101
|
+
destroy() {
|
|
102
|
+
destroyed = true;
|
|
103
|
+
if (timer !== null) timers.clear(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
live?.controller.abort();
|
|
106
|
+
live = null;
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY WORD THE LANE EDITOR DRAWS, over the kit's (36 §3.13). English, and
|
|
3
|
+
* naming no domain: a step, a source, a lane, a value. A consumer lays its own
|
|
4
|
+
* words over these with the editor's `strings` option.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const count = (n) => (Number.isFinite(Number(n)) ? Number(n).toLocaleString('en-US') : String(n));
|
|
8
|
+
const plural = (n, one, many) => (Number(n) === 1 ? one : many.replace('{n}', count(n)));
|
|
9
|
+
|
|
10
|
+
export const LANE_STRINGS = Object.freeze({
|
|
11
|
+
// ── the lanes ──────────────────────────────────────────────────────
|
|
12
|
+
lanesLabel: 'The flow, left to right',
|
|
13
|
+
laneName: (n, title) => `Lane ${n}: ${title}`,
|
|
14
|
+
laneNameUnconnected: (n, title) => `Lane ${n}: ${title} — not connected`,
|
|
15
|
+
notConnected: 'Not connected',
|
|
16
|
+
cardLabel: (kind, title, status) => (status ? `${kind}: ${title}, ${status}` : `${kind}: ${title}`),
|
|
17
|
+
addAfter: (title) => `Add a step after ${title}`,
|
|
18
|
+
addSourceTitle: 'Add a source',
|
|
19
|
+
startsLane: 'starts a new lane',
|
|
20
|
+
afterStep: (title) => `after ${title}`,
|
|
21
|
+
addHint: '“+” on a step adds the next one. A join takes its other input from another lane; '
|
|
22
|
+
+ 'the lines are drawn for you, never by hand.',
|
|
23
|
+
emptyFlow: 'Nothing here yet. A source starts the first lane.',
|
|
24
|
+
stepMenu: (title) => `${title}: what to do`,
|
|
25
|
+
openSettings: 'Open its settings',
|
|
26
|
+
addStepAfter: 'Add a step after it',
|
|
27
|
+
|
|
28
|
+
// ── the toolbar ────────────────────────────────────────────────────
|
|
29
|
+
toolbar: 'Flow',
|
|
30
|
+
readOnlyTitle: 'This flow is open to read only.',
|
|
31
|
+
|
|
32
|
+
// ── parameters ─────────────────────────────────────────────────────
|
|
33
|
+
parameters: 'Parameters',
|
|
34
|
+
addParameter: 'Add parameter',
|
|
35
|
+
parameterTitle: 'Parameter',
|
|
36
|
+
parameterLine: (type, value) => (value === undefined ? String(type ?? '') : `${type ?? ''} · default ${value}`),
|
|
37
|
+
parameterAria: (name, line) => `Parameter ${name}: ${line}`,
|
|
38
|
+
parameterHelp: (ref) => `Steps read it as ${ref}.`,
|
|
39
|
+
parameterSupplied: 'Whatever starts the flow supplies it.',
|
|
40
|
+
parameterName: 'Name',
|
|
41
|
+
parameterType: 'Type',
|
|
42
|
+
parameterDefault: 'Default',
|
|
43
|
+
parameterNoDefault: 'No default',
|
|
44
|
+
parameterDescription: 'Description',
|
|
45
|
+
parameterSave: 'Save',
|
|
46
|
+
parameterCancel: 'Cancel',
|
|
47
|
+
parameterRemove: 'Remove parameter',
|
|
48
|
+
parameterNameRule: 'A name starts with a letter or _ and goes on with letters, digits or _.',
|
|
49
|
+
parameterNameTaken: (name) => `There is already a parameter called ${name}.`,
|
|
50
|
+
parameterNotNumber: 'The default must be a number.',
|
|
51
|
+
parameterNotInteger: 'The default must be a whole number.',
|
|
52
|
+
|
|
53
|
+
// ── a step's settings ──────────────────────────────────────────────
|
|
54
|
+
thisLane: 'This lane',
|
|
55
|
+
thisLaneHelp: 'The step before it, in its own lane.',
|
|
56
|
+
connectIt: 'Choose the step it follows.',
|
|
57
|
+
joinedWith: 'Joined with',
|
|
58
|
+
joinedWithPort: (label) => `Joined with (${label})`,
|
|
59
|
+
joinedWithHelp: 'Any step in another lane, to the left of this one.',
|
|
60
|
+
laneRelation: (delta, lane) => (delta === 0 ? 'this lane' : delta === 1 ? 'the lane below'
|
|
61
|
+
: delta === -1 ? 'the lane above' : `lane ${lane}`),
|
|
62
|
+
candidate: (title, relation) => `${title} — ${relation}`,
|
|
63
|
+
chooseStep: 'Choose a step to see its settings.',
|
|
64
|
+
flowTab: 'The flow',
|
|
65
|
+
flowTitle: 'The flow',
|
|
66
|
+
flowType: 'Settings',
|
|
67
|
+
stepsLabel: 'Steps',
|
|
68
|
+
|
|
69
|
+
// ── the dock ───────────────────────────────────────────────────────
|
|
70
|
+
dataLabel: 'What this step gives',
|
|
71
|
+
previewTab: 'Preview',
|
|
72
|
+
columnsTab: (n) => `Columns · ${count(n)}`,
|
|
73
|
+
rejectsTab: (n) => `Would be rejected · ${count(n)}`,
|
|
74
|
+
previewOn: (ms) => `Preview on · refreshed ${count(ms)} ms after your last change`,
|
|
75
|
+
previewing: 'Previewing…',
|
|
76
|
+
previewFailed: 'The preview could not be read.',
|
|
77
|
+
previewOff: 'No preview here.',
|
|
78
|
+
noPreview: 'Nothing previewed for this step yet.',
|
|
79
|
+
previewCaption: (shown, total) => `First ${count(shown)} of ${count(total)} previewed rows.`,
|
|
80
|
+
columnsFromDescription: (n) => `${plural(n, '1 column', '{n} columns')}, from the step’s description — no run needed.`,
|
|
81
|
+
columnsFromPreview: (n) => `${plural(n, '1 column', '{n} columns')}, from the preview.`,
|
|
82
|
+
columnsUnknown: 'No columns known for this step yet.',
|
|
83
|
+
columnNew: 'new',
|
|
84
|
+
columnChanged: 'changed',
|
|
85
|
+
rejectsCaption: (n) => `${plural(n, '1 of the previewed rows would be rejected.', '{n} of the previewed rows would be rejected.')}`,
|
|
86
|
+
noRejects: 'No previewed row would be rejected.',
|
|
87
|
+
rejectReason: (column, reason) => (column ? `${column}: ${reason}` : String(reason ?? '')),
|
|
88
|
+
noRows: 'No rows.',
|
|
89
|
+
|
|
90
|
+
// ── what a card's status line says ─────────────────────────────────
|
|
91
|
+
rows: (n) => plural(n, '1 row', '{n} rows'),
|
|
92
|
+
rejected: (n) => `${count(n)} rejected`,
|
|
93
|
+
skipped: 'skipped',
|
|
94
|
+
stepFailed: 'failed in the preview',
|
|
95
|
+
notRun: 'Not in the last run',
|
|
96
|
+
noRun: 'No run to show',
|
|
97
|
+
pending: '…',
|
|
98
|
+
});
|