flexdesk 0.5.0 → 0.6.1
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 +654 -1
- package/css/base.css +2868 -0
- package/css/flexdesk.css +2868 -0
- package/css/tokens.css +72 -0
- package/dist/canvas.js +390 -0
- package/dist/canvas.js.map +7 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-24MS7W5Y.js → chunk-6NG7YVBG.js} +2 -470
- package/dist/chunk-6NG7YVBG.js.map +7 -0
- package/dist/chunk-H4LT43RS.js +475 -0
- package/dist/chunk-H4LT43RS.js.map +7 -0
- package/dist/{chunk-KH2GJTKR.js → chunk-IYV5JVYB.js} +29 -1
- package/dist/chunk-IYV5JVYB.js.map +7 -0
- package/dist/chunk-SN4LSOAR.js +532 -0
- package/dist/chunk-SN4LSOAR.js.map +7 -0
- package/dist/{chunk-CNXP3SIN.js → chunk-ZMCRDOCF.js} +2 -2
- package/dist/flexdesk.css +2868 -0
- package/dist/flow.js +10030 -0
- package/dist/flow.js.map +7 -0
- package/dist/tiles.js +9 -9
- package/dist/tokens.css +72 -0
- package/dist/widgets.js +87 -5
- package/dist/widgets.js.map +3 -3
- package/dist/wm.js +4 -2
- package/dist/wm.js.map +1 -1
- package/package.json +9 -1
- 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/ui/components/data_table.js +40 -0
- package/src/ui/utils/scroll_lock.js +111 -0
- package/dist/chunk-24MS7W5Y.js.map +0 -7
- package/dist/chunk-KH2GJTKR.js.map +0 -7
- /package/dist/{chunk-CNXP3SIN.js.map → chunk-ZMCRDOCF.js.map} +0 -0
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ That is exactly what EcoAgent does:
|
|
|
33
33
|
<link href="./vendor/flexdesk/flexdesk.css" rel="stylesheet" />
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
##
|
|
36
|
+
## Nine entry points
|
|
37
37
|
|
|
38
38
|
Because someone who wants a DataTable should not have to download a window
|
|
39
39
|
manager, and neither of them should drag in a plotting engine or a code editor.
|
|
@@ -47,6 +47,12 @@ manager, and neither of them should drag in a plotting engine or a code editor.
|
|
|
47
47
|
| `@flexdesk/charts` | 26 kB | Plotly wrapper, chart types, subplot layouts, downsampling, plot windows |
|
|
48
48
|
| `@flexdesk/tiles` | 67 kB | Draggable/resizable widget grid, tile base class, widget registry, layout persistence |
|
|
49
49
|
| `@flexdesk/editor` | 62 kB | Monaco loader + factory, multi-file tab bar, search, split panes, undo manager |
|
|
50
|
+
| `@flexdesk/canvas` | 14 kB | The canvas kernel: a pannable, zoomable surface, the edge router, a generic graph layer ([Flow editors](#flow-editors)) |
|
|
51
|
+
| `@flexdesk/flow` | 381 kB | The flow-editor kit and the canvas, outline and lane editors ([Flow editors](#flow-editors)) |
|
|
52
|
+
|
|
53
|
+
The sizes of `@flexdesk/canvas` and `@flexdesk/flow` are the entry files as
|
|
54
|
+
`node build.mjs` reported them for the flow editors' integration build; like
|
|
55
|
+
every entry they also import shared chunks. The other rows are older figures.
|
|
50
56
|
|
|
51
57
|
They share code through chunks, so importing two of them does **not** give you two
|
|
52
58
|
event buses — and therefore not two copies of every module-level singleton. A test
|
|
@@ -315,6 +321,39 @@ behaves as it did in 0.4, except for these:
|
|
|
315
321
|
to it if it is in a cascade layer, or has zero specificity and loads before
|
|
316
322
|
`flexdesk.css`. A rule for the cells, the rows or the table does not.
|
|
317
323
|
|
|
324
|
+
### Comparing two tables: `diff` and `createScrollLock`
|
|
325
|
+
|
|
326
|
+
A side-by-side comparison is two `DataTable`s drawn from ONE aligned row list:
|
|
327
|
+
the rows as they are on one side, as they were on the other, and a gap where
|
|
328
|
+
a side has no row. Both options are off until you set them.
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
import { DataTable, createScrollLock } from '@flexdesk/widgets';
|
|
332
|
+
|
|
333
|
+
const lock = createScrollLock({ axis: 'y' }); // 'x', 'y' or 'both'
|
|
334
|
+
const side = (host, pick) => DataTable.mount(host, {
|
|
335
|
+
headers, rows: aligned.map((r) => pick(r) ?? blank), pagination: false,
|
|
336
|
+
scrollLock: lock,
|
|
337
|
+
diff: (_row, i) => ({ status: statusOf(aligned[i], pick), cells: changedCols(aligned[i]) }),
|
|
338
|
+
});
|
|
339
|
+
side(left, (r) => r.now);
|
|
340
|
+
side(right, (r) => r.then);
|
|
341
|
+
lock.setEnabled(false); // scroll each side on its own; true brings them level
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
- `diff: (row, rowIdx) => {status, cells?}` draws the row
|
|
345
|
+
`twm-dt-diff twm-dt-diff--<status>`, for `added`, `removed`, `changed`,
|
|
346
|
+
`same` or `gap`, and each column index in `cells` (an array or a Set)
|
|
347
|
+
`twm-dt-diff__cell--changed`. `null` or any other status draws the row as
|
|
348
|
+
without `diff`. A `gap` row's cells are drawn empty, whatever its values and
|
|
349
|
+
`renderCell`, so the two sides keep their rows level. `updateRow` asks again.
|
|
350
|
+
- `scrollLock` takes a group from `createScrollLock()`. The table's body box
|
|
351
|
+
joins it on every render and leaves on the next render and on `dispose()`.
|
|
352
|
+
Any element can join with `lock.attach(el)`, which returns its detach. A
|
|
353
|
+
box that joins starts at the group's last position, and the echo of a
|
|
354
|
+
position the lock itself wrote moves nobody back. The lock copies pixels, so
|
|
355
|
+
it lines rows up only when both sides draw rows of the same height.
|
|
356
|
+
|
|
318
357
|
### Scrollbars
|
|
319
358
|
|
|
320
359
|
`installAutoScrollbars(root)` (from `@flexdesk/widgets`) replaces the browser's
|
|
@@ -325,6 +364,620 @@ layer, so a container that redraws its contents cannot lose its bar, and
|
|
|
325
364
|
textareas get one too. Containers that already use `installOverlayScrollbar`
|
|
326
365
|
keep their own.
|
|
327
366
|
|
|
367
|
+
## Flow editors
|
|
368
|
+
|
|
369
|
+
*New in 0.6.0.* Both entries first ship in FlexDesk 0.6.0.
|
|
370
|
+
|
|
371
|
+
`@flexdesk/flow` holds three editors for flows — a graph of steps, each with
|
|
372
|
+
settings — and the kit they share: a **canvas** (nodes and lines), an
|
|
373
|
+
**outline** (the graph drawn as a list of blocks) and **lanes** (data flows,
|
|
374
|
+
left to right). All three are domain-neutral. An editor never fetches, never
|
|
375
|
+
saves and never starts a timer that writes: the step types, the reference
|
|
376
|
+
grammar, every finding and every byte of I/O are the consumer's, handed in as
|
|
377
|
+
options and handed back through callbacks. `@flexdesk/canvas` is the kernel
|
|
378
|
+
under the canvas editor (and under any other diagram), on its own so that a
|
|
379
|
+
diagram does not download the editors.
|
|
380
|
+
|
|
381
|
+
Nothing here changes an existing entry. Both are new entry points, and their
|
|
382
|
+
CSS is four new sections at the end of `flexdesk.css`, read only by the classes
|
|
383
|
+
these modules set.
|
|
384
|
+
|
|
385
|
+
```js
|
|
386
|
+
import {
|
|
387
|
+
createStepCatalogue, createWidgetRegistry, createSettingsPanel,
|
|
388
|
+
TEMPLATE_REFERENCES, FlowHistory, bindFlowKeys,
|
|
389
|
+
} from '@flexdesk/flow';
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### The kit
|
|
393
|
+
|
|
394
|
+
The parts every editor stands on, exported for a consumer that builds its own.
|
|
395
|
+
A test (`tests/flow_kit_vocabulary.test.mjs`) fails when a file under
|
|
396
|
+
`src/flow/` or `src/canvas/` names a consumer's vocabulary, calls `fetch` or
|
|
397
|
+
touches browser storage — and is shown to fail on a planted one.
|
|
398
|
+
|
|
399
|
+
| Part | What it does |
|
|
400
|
+
|---|---|
|
|
401
|
+
| `createStepCatalogue(types, {categories, idBase})` | The step types the consumer passes in, read as they arrive (`type_id`, `label`, `category`, `role`, `description`, `icon`, `ports`, `config_schema`, `unavailable`; any other key is kept). `get`, `list`, `byCategory`, `role`, `tone`, `inputs(typeId, {flow})`, `outputs`, `port`, `idBase`, and `problems` — a broken type is listed there, never thrown. **Ports keep their declared order**: a lane reads the first input as its own and an outline draws the other outputs as arms in order, so nothing sorts them |
|
|
402
|
+
| `checkSettingsSchema(schema)` | The JSON-Schema subset a step's settings may use: `type`, `enum`, `const`, `required`, `properties`, `additionalProperties` (a boolean), `items`, `minItems`/`maxItems`, `minimum`/`maximum`, `minLength`/`maxLength`, `format: "uuid"`, the annotations and any `x-` hint. Returns the first refusal as the server's own sentence, or `null`. `oneOf`, `anyOf`, `$ref` and `pattern` are refused. `tests/fixtures/flow_kit_settings_schema.json` holds 30 schemas and their verdicts. Tables' own `check_schema` gave the same 30 verdicts when the file was written, and is meant to read this file in a test of its own |
|
|
403
|
+
| `createWidgetRegistry()` | The controls, by name. The ten generic widgets are registered already; `register(name, widget)` throws for a name that exists, and replacing one takes `{replace: true}`. A widget is `(spec, value, ctx) => {el, destroy?, focus?, caret?, restoreCaret?}` and reports with `ctx.set(value)`; `undefined` removes the setting |
|
|
404
|
+
| `createSettingsPanel(options)` | One step's settings, or the flow's own, drawn from the schema (below) |
|
|
405
|
+
| `createReferenceSyntax({name, pattern, format, describe, stepScope})` | What a chip is. Three are ready: `TEMPLATE_REFERENCES` (`${a.b[0]}`), `FORMULA_REFERENCES` (`[a.b]`), `PARAMETER_REFERENCES` (`{{name}}`). `find`, `format`, `rename(text, map)` and the consumer's `describe(match, ctx) → {label, tone, known}` |
|
|
406
|
+
| `createChipInput({value, syntaxes, multiline, readOnly, onInput})` | Text with references drawn as chips. **The value is always the plain text**; a chip is a view of it |
|
|
407
|
+
| `openStepPicker({anchor, entries, categories, where, paste})` | What "+" opens: grouped, searchable, ↑ ↓ and Enter; a refused entry is shown greyed with its reason. Resolves `{entry}`, `{paste: true}` or `null` |
|
|
408
|
+
| `openValuePicker({anchor, groups, note, onPick})` | *Insert a value*: the consumer's groups of values, a level down for a value with children (`body ›`), searchable across levels |
|
|
409
|
+
| `alwaysBefore(graph, catalogue, stepId, {loopPorts, waitsForAll})` | The steps that run before this one on every path, nearest first: the only ones *Insert a value* should offer. A parallel whose merge waits for every branch counts each branch's steps; a loop's body never counts for a step after the loop. `enclosingLoops` lists the loops around a step |
|
|
410
|
+
| `groupFindings(list)`, `createFindingsStrip({onGoTo, nameOf})` | The consumer's findings (`{code, message, severity, node_id?, field?}`) by step, and the strip: *"N things to fix before this can be published."*, a line each, *Go to it* |
|
|
411
|
+
| `new FlowHistory({actions, restore, onState, limit, mergeMs})` | Undo and redo over whole snapshots. The editor passes its own list of actions and `commit` throws on any other, so an edit nobody named fails its first test. Loading and publishing are `baseline`s, never entries. Keyed commits within `mergeMs` merge (typing a name is one entry), never across an undo. `keep()` and `adopt(kept, state)` carry the stacks to a successor that loaded the same bytes |
|
|
412
|
+
| `bindFlowKeys(root, handlers)`, `ownsUndo(target)` | An editor's keys, bound on its root, never on `window` (below) |
|
|
413
|
+
| `emptyGraph`, `normalise`, `serialise`, `nextId`, `addNode`, `removeNode`, `connect`, `canConnect`, `disconnect`, `inputNamesOf` | A logic flow's graph — `{nodes: [{id, type, label?, config, position}], connections: [{source, target, sourcePort, targetPort}]}`, the shape the canvas and the outline both save. `serialise` is byte-stable: a graph opened and saved with no edit is the same text |
|
|
414
|
+
| `createStrings(...layers)`, `FLOW_STRINGS` | Every word drawn, English and domain-free by default; an editor lays its own words over these, and the consumer lays its words over both |
|
|
415
|
+
|
|
416
|
+
**The settings panel.**
|
|
417
|
+
|
|
418
|
+
```js
|
|
419
|
+
const panel = createSettingsPanel({
|
|
420
|
+
widgets, references: { template: TEMPLATE_REFERENCES },
|
|
421
|
+
onChange: (stepId, key, value) => apply(stepId, key, value), // you write it into your graph
|
|
422
|
+
onRename: (stepId, label) => rename(stepId, label),
|
|
423
|
+
onFocusLost: () => surface.focus(),
|
|
424
|
+
values: async ({ stepId, field }) => groupsFor(stepId), // Insert a value; absent, no "{ }"
|
|
425
|
+
columns: (step) => inputColumnsOf(step), // upstream-column(s); null in a logic flow
|
|
426
|
+
host,
|
|
427
|
+
});
|
|
428
|
+
panel.show({ step, type, title, typeLabel, where, description, idLine, icon, tone,
|
|
429
|
+
rename: true, extra: [...], actions: [...], slots: { before, after } });
|
|
430
|
+
panel.setFindings(findings); // under each field, the rest at the top; rebuilds nothing
|
|
431
|
+
panel.focusField('retry.max_attempts');
|
|
432
|
+
panel.repaintSlots(); // your areas only; no field is rebuilt
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
It never writes into the step: a change is reported to `onChange`, and the
|
|
436
|
+
consumer writes it. A redraw puts focus back on the rebuilt control of the same
|
|
437
|
+
field, with the caret where it was, or hands it to `onFocusLost`, never to
|
|
438
|
+
`<body>`. Findings and slot redraws rebuild no field, so a run overlay that
|
|
439
|
+
refreshes every second leaves a name being typed alone. A control that cannot
|
|
440
|
+
be used is disabled and its reason is shown next to it, not only in a tooltip.
|
|
441
|
+
`extra` holds the editor's own fields (a port shown as a setting) and notes,
|
|
442
|
+
placed with `after: '<key>'` or `at: 'start'`.
|
|
443
|
+
|
|
444
|
+
**The widgets.** A schema type with no `x-ui-widget` draws its plain control: an
|
|
445
|
+
`enum` a select (writing the enum's own value, so a number stays a number), a
|
|
446
|
+
string a text box, a number a number box, a boolean a checkbox, an object with
|
|
447
|
+
properties a group of its own fields. A control never writes a value the reader
|
|
448
|
+
did not give it. An untouched setting stays absent, and emptying one removes it.
|
|
449
|
+
A list or map entry keeps its stored type when another entry is edited. A widget
|
|
450
|
+
name nobody registered is refused on screen, by name, and its value is kept.
|
|
451
|
+
|
|
452
|
+
**A widget is built before it is in the document.** The panel calls the widget,
|
|
453
|
+
then attaches what it returns, and a field inside a folded (`x-ui-fold`) group
|
|
454
|
+
is built inside a closed `<details>`. A widget that measures itself — a Monaco
|
|
455
|
+
editor registered as `expression` — must create that editor once its element is
|
|
456
|
+
connected and has a size (a `ResizeObserver` on `el` will tell it), never inside
|
|
457
|
+
the widget call: an editor built into a box with no size measures a zero
|
|
458
|
+
character width and paints a viewport of no lines, and nothing repaints it.
|
|
459
|
+
|
|
460
|
+
| Generic widget | Draws |
|
|
461
|
+
|---|---|
|
|
462
|
+
| `template` | Text with chips and *Insert a value*. A setting that may also be a list or an object is shown as JSON and written back as one when the text parses |
|
|
463
|
+
| `expression` | A formula: monospace, with chips. Replace it with your own editor (`{replace: true}`) |
|
|
464
|
+
| `json-body` | JSON when the text parses, text otherwise |
|
|
465
|
+
| `key-value-map` | Names to values |
|
|
466
|
+
| `key-value-list` | Ordered assignments: `{variable, value}` or `{variable, expression}` |
|
|
467
|
+
| `string-list` | Short strings |
|
|
468
|
+
| `enum-chips` | A multi-select of `items.enum`, as chips, in the enum's order |
|
|
469
|
+
| `choice-cards` | One of N as radio rows, each with its words |
|
|
470
|
+
| `upstream-column`, `upstream-columns` | One or several columns of the step's input, by name, from the panel's `columns` |
|
|
471
|
+
|
|
472
|
+
| Schema hint | Meaning |
|
|
473
|
+
|---|---|
|
|
474
|
+
| `x-ui-widget` | The widget that draws the field |
|
|
475
|
+
| `x-ui-references` | The syntaxes its chips are recognised in, by name. Without it, `template`, `json-body` and the two key-value widgets use `template`, and `expression` uses `formula` |
|
|
476
|
+
| `x-ui-when` | `{field, in}`: drawn only while that sibling holds one of those values (its stored value, else its default) |
|
|
477
|
+
| `x-ui-placeholder`, `x-ui-multiline` | A placeholder; a string as a growing textarea |
|
|
478
|
+
| `x-ui-enum-labels`, `x-ui-enum-descriptions` | An enum's words, and a sentence under each choice card |
|
|
479
|
+
| `x-ui-fold` | An object drawn folded, with a one-line summary of what is set in it |
|
|
480
|
+
| `x-ui-add-label` | The words on a list's *Add* button |
|
|
481
|
+
| `x-ui-formula-references` | `key-value-list`: the syntaxes of a formula row (default `formula`) |
|
|
482
|
+
|
|
483
|
+
**References and chips.** A syntax recognises references in order to draw them.
|
|
484
|
+
It is not a grammar. A reference your server would refuse is still drawn as a chip
|
|
485
|
+
(`describe` can mark it `known: false`) and kept as text. Each syntax has a
|
|
486
|
+
default tone for its chips (`PARAMETER_REFERENCES` is violet, the others blue),
|
|
487
|
+
so two syntaxes in one field read as two kinds of chip. In the chip input the
|
|
488
|
+
browser does the typing, so an IME works. After every input the text is read
|
|
489
|
+
back and re-tokenised. Everything else the control does on the text itself:
|
|
490
|
+
← and → step over a chip, one Backspace removes a whole chip, a paste is plain
|
|
491
|
+
text, a copy puts the exact text on the clipboard, and Enter adds a line only in
|
|
492
|
+
a multi-line field. It keeps its own undo, because a script that redraws a
|
|
493
|
+
`contenteditable` breaks the browser's. `rename(text, map)` rewrites only the
|
|
494
|
+
path segment that names a step, and only once the syntax is told which segment
|
|
495
|
+
that is (`stepScope: 'steps'`, or a `stepSegment` function). A syntax nobody
|
|
496
|
+
configured renames nothing.
|
|
497
|
+
|
|
498
|
+
**Keys.** `bindFlowKeys(root, handlers)` binds Ctrl/⌘+Z (`undo`), Ctrl/⌘+Y and
|
|
499
|
+
Ctrl/⌘+Shift+Z (`redo`), Delete (`remove`), Ctrl/⌘+D, C, X and V (`duplicate`,
|
|
500
|
+
`copy`, `cut`, `paste`), F2 (`rename`), Alt+↑, Alt+↓ and Alt+← (`moveUp`,
|
|
501
|
+
`moveDown`, `moveOut`), the arrows (`up`, `down`, `left`, `right`), Enter
|
|
502
|
+
(`open`) and Escape (`escape`), and returns an unbind. A key whose handler is
|
|
503
|
+
absent, or returns `false`, is not taken. An editor's own row or card marks
|
|
504
|
+
itself `data-twm-flow-item`, so Enter on it opens it. Inside a text field Ctrl+Z is the field's own, but it is
|
|
505
|
+
still stopped at the editor's root, so a tile beside the editor never hears it.
|
|
506
|
+
In a field every other key belongs to the field, and Enter on a button belongs
|
|
507
|
+
to the button. **Backspace is never an editor key.** It is not handled and not
|
|
508
|
+
stopped, because a host may use it to go back a level.
|
|
509
|
+
|
|
510
|
+
**Tones and strings.** A category names a tone (`violet`, `teal`, `amber`,
|
|
511
|
+
`grey`, `blue`, `indigo`), and `tokens.css` maps each tone to colours:
|
|
512
|
+
`--twm-flow-<tone>-bg`, `-fg`, `-line`, `-icon-bg`, `-text-bg` and `-dot`.
|
|
513
|
+
|
|
514
|
+
**What it does not do.** It does not validate a value against its schema, decide
|
|
515
|
+
whether a reference is valid, save, fetch, or keep anything between page loads.
|
|
516
|
+
Those belong to the consumer.
|
|
517
|
+
|
|
518
|
+
**Where each claim was checked.** jsdom computes no layout and does no editing,
|
|
519
|
+
so the suites (`tests/flow_kit_*.test.mjs`) assert the DOM, the events and the
|
|
520
|
+
text. The rest was checked in headless Edge with real input over the DevTools
|
|
521
|
+
protocol: `demo/flow_kit_probe.mjs` serves the repository, opens
|
|
522
|
+
`demo/flow_kit.html` and drives it (`demo/flow_cdp.mjs` is the small driver it
|
|
523
|
+
uses, for the editors' pages too). The demo pages load the source barrels;
|
|
524
|
+
`FLOW_DIST=1 node demo/flow_<kit|canvas|outline|lanes>_probe.mjs` redirects
|
|
525
|
+
`flow.js`, `canvas.js` and the three sheets to `dist/`, so the same checks run
|
|
526
|
+
against the built bundle. All four probes passed both ways on the integration
|
|
527
|
+
build. In the chip input, typing on either side of
|
|
528
|
+
a chip, ← and → over it, one Backspace removing it, a real paste of `${a.b}`
|
|
529
|
+
becoming a chip, a real copy giving the exact text, an IME composition leaving
|
|
530
|
+
the text alone until it committed, and the field's own Ctrl+Z all passed. The
|
|
531
|
+
same Backspace in a plain `contenteditable` whose chips are not
|
|
532
|
+
`contenteditable="false"` split its chip. A pointer drag across a chip started
|
|
533
|
+
no native drag. `elementFromPoint` at the centre of Undo, "+", the panel's
|
|
534
|
+
first field, its title, a field's "{ }", an action, a choice and the strip's
|
|
535
|
+
*Go to it* returned each one. Both pickers opened inside the viewport, their
|
|
536
|
+
search box focused, and gave the focus back to "+" on Escape.
|
|
537
|
+
|
|
538
|
+
### The canvas editor
|
|
539
|
+
|
|
540
|
+
A flow drawn as nodes and lines: a palette of step types, a pannable canvas and
|
|
541
|
+
the settings of whatever is picked, under a toolbar. It stands on the
|
|
542
|
+
`@flexdesk/canvas` kernel, which is also usable on its own for any diagram.
|
|
543
|
+
|
|
544
|
+
**The kernel** (`@flexdesk/canvas`).
|
|
545
|
+
|
|
546
|
+
| Part | What it does |
|
|
547
|
+
|---|---|
|
|
548
|
+
| `new CanvasAdapter({container, gridSize = 16, onViewportChange, onBackgroundMenu, nodeSelector})` | A surface you put nodes and lines on: right-, middle- or Shift-drag pans, Ctrl + the wheel zooms about the pointer, a left press on the background draws a marquee (`onMarquee(box)`, `onSelectionChange(selection)`). Fields `root`, `surface`, `edgeLayer` (an SVG), `nodeLayer`, `marquee`, `selection`, `scale`, `tx`, `ty`, `gridSize`. Methods `clientToCanvas`, `canvasToClient`, `snap`, `setViewport`, `viewport`, `zoomAt`, `fit`, `visibleBox`, `contains`, `bringIntoView` (pans and never zooms, and does nothing when the box is already in view), `consumeRightDrag`, `clear`, `destroy`. **A node is whatever you put in `nodeLayer`**: a press inside one starts no pan and no marquee, and a right-click on one keeps your menu (or the browser's). Pass `nodeSelector` when the node layer holds other things too. `onViewportChange` fires once, synchronously, from the constructor, so declare what it reads before you build the adapter |
|
|
549
|
+
| `routeEdge(from, to, {obstacles, simple})`, `selfLoop`, `returnEdge(from, to, {floor})` | SVG path data between two anchors `{x, y, side}`: a line with horizontal lead-outs (bent round obstacles up to `OBSTACLE_ROUTING_NODE_LIMIT` nodes), a node's line to itself, and a loop's line back, which runs under both nodes |
|
|
550
|
+
| `crowsFoot(point, side, kind)`, `footAnchor(point, side, kind)`, `cardinality({kind, optional})` | Crow's-foot ends (`many`, `one`, `zero-or-many`, `one-or-many`, `zero-or-one`, `exactly-one`) as an SVG group `twm-edge__marker twm-edge__marker--<kind>`, where the line should start so it meets the mark, and the two ends a reference implies |
|
|
551
|
+
| `arrange(nodes, edges, {columnGap, rowGap})` | A layered layout. **Two quirks, kept on purpose:** an edge points from child to parent (a node sits one column right of what it points at), and each column is sorted by `label`, which every node must have |
|
|
552
|
+
| `new NodePlatform({collapsedPorts})` | The nodes and their ports: `hydrate(nodes)`, `get`, `all`, `port`, `setPosition`, `setCollapsed`, `visiblePorts(id)`, `on(event, fn)` (`hydrated`, `moved`, `collapsed`). A node is `{id, position, size, collapsed, ports, …}`; any other key is kept |
|
|
553
|
+
| `new ConnectorRouter({platform, selfReference, policy, endsOf, disabledEnds, strings})` | Edges and whether a drop may become one. `canConnect(source, target, {pendingEdges})` refuses, in order, a node that is gone, a port that is gone, a disabled port (with its `disabledReason`), a self-reference (unless `selfReference`), and a port at its `maxConnections`. Only then does it ask your `policy(ctx)`, which may refuse, allow, or answer `{ok: true, offer}`. Also `hydrate`, `edges` (a Map), `edgesFor`, `stage`, `unstage`, `remapNodeConnections(nodeId, portIds)` (keeps the edges whose ports survive and reports the rest) |
|
|
554
|
+
| `new HistoryService({actions, apply, onState, limit, requires, refusal})` | Undo over recorded edits (deltas), behind a closed action list: `record` throws on any action you did not declare. Nothing is recorded before `hydrated()`, and a batch (`startBatch`/`endBatch`) is one entry. Also `undo`, `redo`, `reset`, `adopt`, `undoStack`, `redoStack`, `canUndo`, `canRedo`. A missing `actions`, `apply` or anything named in `requires` throws at construction, not at the first silent no-op |
|
|
555
|
+
|
|
556
|
+
A diagram with its own rules subclasses the three and passes them in. For
|
|
557
|
+
example, a schema diagram's router answers the generic refusals in its own
|
|
558
|
+
words and keeps its foreign-key rules in the policy:
|
|
559
|
+
|
|
560
|
+
```js
|
|
561
|
+
class ConnectorRouter extends Base.ConnectorRouter {
|
|
562
|
+
constructor({ platform }) {
|
|
563
|
+
super({ platform, selfReference: true, disabledEnds: ['source'],
|
|
564
|
+
endsOf: (e) => ({ from: `col:${e.from_col}`, to: `col:${e.to_col}` }),
|
|
565
|
+
strings: { nodeGone: 'That table is no longer on the diagram.' },
|
|
566
|
+
policy: (ctx) => myForeignKeyRules(ctx) });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
`tests/canvas_graph.test.mjs` §7 builds such a diagram on the generic layer.
|
|
572
|
+
|
|
573
|
+
**The editor.**
|
|
574
|
+
|
|
575
|
+
```js
|
|
576
|
+
import { createCanvasEditor, createStepCatalogue } from '@flexdesk/flow';
|
|
577
|
+
|
|
578
|
+
const editor = createCanvasEditor(host, {
|
|
579
|
+
catalogue: createStepCatalogue(types, { categories }),
|
|
580
|
+
flowSettings: { title: 'Sync', schema, value }, // the flow's own settings, shown when nothing is picked
|
|
581
|
+
actions: [{ id: 'publish', label: 'Publish', icon: 'publish', primary: true, run: (ed) => publish(ed) }],
|
|
582
|
+
onChange: ({ graph, flowSettings, text, action, key, via }) => autosave(text),
|
|
583
|
+
values: async ({ stepId, field, before, loops }) => groups, // Insert a value
|
|
584
|
+
dataPortRead: (node, port) => `\${steps.${node.id}.${port.name}}`,
|
|
585
|
+
summariseReads: (node) => `Later steps read it as \${steps.${node.id}.…}`,
|
|
586
|
+
});
|
|
587
|
+
editor.load({ graph }); // a baseline: there is nothing to undo after it
|
|
588
|
+
editor.setFindings(validatorResult); // on the nodes, under the fields, in the strip
|
|
589
|
+
editor.setRunOverlay({ steps, ports, banner });
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
| Option | Meaning |
|
|
593
|
+
|---|---|
|
|
594
|
+
| `catalogue` | `createStepCatalogue(...)`, or the raw list of types. The palette shows them by category, except start-role types (`paletteTypes(type)` decides). A type with `unavailable` is shown greyed, with its sentence under it |
|
|
595
|
+
| `widgets`, `references`, `strings`, `services` | The kit's widget registry, the reference syntaxes by name, your words (`CANVAS_STRINGS` and the kit's), and your bag, handed to every widget unchanged |
|
|
596
|
+
| `values` | *Insert a value*: called with `{graph, stepId, field, key, before, loops}`, where `before` is the steps that always run before this one (`alwaysBefore`, nearest first) and `loops` the loops around it. Returns groups, or `{groups, note}` |
|
|
597
|
+
| `summarise(node, type)` | A line under a node's ports. `summariseReads(node, type)` is the panel's id line |
|
|
598
|
+
| `dataPortRead(node, port)` | How a data port is read, in your syntax. It is shown on the port, which cannot be connected |
|
|
599
|
+
| `flowSettings` | `{title, schema, value}` — the flow's own settings, edited as `flow:settings` |
|
|
600
|
+
| `readOnly` | `false`, or `{reason}`, which is shown above the editor. Every verb is then refused with that reason, and no gesture writes |
|
|
601
|
+
| `actions` | Your toolbar verbs, `[{id, label, icon, primary, danger, title, run(editor)}]`, after Undo, Redo, Arrange and Fit |
|
|
602
|
+
| `slots` | `toolbarEnd(el)`, `flowPanel(el, editor)` and `stepPanel(el, node, editor)`, drawn by you; `setRunOverlay` redraws the panel slots and nothing else |
|
|
603
|
+
| `onChange`, `onSelect` | After every committed edit. An undo or a redo is a change too (`action: null`, `via: 'undo' \| 'redo'`), because you autosave what is undone. `onSelect({kind, id})`: `step`, `flow`, `connection` (with `connection`) or `null` |
|
|
604
|
+
| `loop`, `waitsForAll` | The loop's port names (`{entry: 'in', next, body, done}`): a line into a loop-role step on any input but `entry` is its body going back round, drawn dashed under both steps. `waitsForAll(join)` goes to `alwaysBefore` |
|
|
605
|
+
| `removable(node, type)` | Whether a step may be removed. By default every step except a start step |
|
|
606
|
+
| `node` | `{width: 208, header: 34, row: 22}` (`CANVAS_NODE`). Lines are anchored by arithmetic over these numbers, and the stylesheet is held to them |
|
|
607
|
+
| `history`, `gridSize`, `label` | `{limit, mergeMs}`, the snap grid, and the canvas's accessible name |
|
|
608
|
+
|
|
609
|
+
| Method | Does |
|
|
610
|
+
|---|---|
|
|
611
|
+
| `load({graph, flowSettings}, {baseline = true, fit = true})` | Replaces the content. A baseline clears both stacks. With `baseline: false` the stacks are kept, and the loaded content becomes the present they undo from |
|
|
612
|
+
| `getGraph()`, `serialise()`, `snapshot()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the text the history holds (graph and settings); the settings |
|
|
613
|
+
| `setFindings(list)`, `setRunOverlay(overlay \| null)`, `setReadOnly(false \| {reason})` | — |
|
|
614
|
+
| `setActionState(id, {disabled, reason, busy})`, `setStatus(text)`, `setMessage(text, tone)` | A verb refused in place, with its reason printed beside the toolbar; the status line; the gesture line |
|
|
615
|
+
| `select(id \| 'flow' \| null)`, `repaintPanel()`, `focus()`, `undo()`, `redo()`, `arrange()`, `fit({whole})` | — |
|
|
616
|
+
| `history`, `canvas` | The editor's `FlowHistory` (`keep()`, `adopt(kept, editor.snapshot())`, `baseline(editor.snapshot())` for your publish) and its `CanvasAdapter` |
|
|
617
|
+
| `destroy()` | Removes every listener, the ghost of a drag and any picker it opened. It saves nothing, because the editor holds nothing |
|
|
618
|
+
|
|
619
|
+
Its edits are the closed list `CANVAS_ACTIONS`: `flow:node:add`,
|
|
620
|
+
`flow:node:remove`, `flow:node:move` (one per drag), `flow:edge:connect`,
|
|
621
|
+
`flow:edge:disconnect`, `flow:node:label`, `flow:node:config`,
|
|
622
|
+
`flow:settings`, `flow:arrange` (one entry for the whole layout). Typing a name
|
|
623
|
+
or a setting is one entry. The geometry under it is exported as well, all of it
|
|
624
|
+
pure: `CANVAS_NODE`, `arrangeCanvasGraph(graph, catalogue, {loopEntry})` (where
|
|
625
|
+
Arrange puts each step: a Map of id → `{x, y}` from the origin),
|
|
626
|
+
`canvasPortAnchor`, `canvasNodeBottom` and `isCanvasReturnEdge`.
|
|
627
|
+
|
|
628
|
+
**Gestures.** A node's header moves it. A connection is made by clicking an
|
|
629
|
+
output and then an input, and Escape cancels one in progress. A line is picked
|
|
630
|
+
by clicking it, and Delete removes it or the picked step. A palette step is
|
|
631
|
+
added by clicking it (at the centre of the view) or by dragging it onto the
|
|
632
|
+
canvas (where it is dropped). The drag uses pointer events and a ghost on
|
|
633
|
+
`document.body`. It never uses `draggable`, which cancels the pointer after
|
|
634
|
+
about 5px, and never `setPointerCapture`, which would send the release, and so
|
|
635
|
+
a second click, to the palette button. No press rebuilds a node, so a click lands
|
|
636
|
+
on the node it went down on. Arrange lays the run out left to right, a branch's
|
|
637
|
+
arms in their declared order, from the graph's own top-left corner. Fit frames
|
|
638
|
+
the graph at natural size or smaller; pressing Fit frames all of it.
|
|
639
|
+
|
|
640
|
+
**Keys** (bound on the editor's root): Ctrl/⌘+Z and Ctrl/⌘+Y or Ctrl/⌘+Shift+Z,
|
|
641
|
+
stopped at the root even inside a text field; Delete; F2 renames the picked
|
|
642
|
+
step; Enter on a focused node opens its settings; Escape. Backspace is never
|
|
643
|
+
the editor's.
|
|
644
|
+
|
|
645
|
+
**What it does not do.** It does not load, save, validate, publish or run
|
|
646
|
+
anything, and it does not keep the stacks across page loads. Those belong to
|
|
647
|
+
the consumer. Nor does it add anything a flow must have: a consumer whose new
|
|
648
|
+
flow opens on a start step adds that step to the graph before `load`, so it is
|
|
649
|
+
part of the baseline and the first Ctrl+Z does not take it away.
|
|
650
|
+
|
|
651
|
+
**Where each claim was checked.** `tests/canvas_adapter.test.mjs`,
|
|
652
|
+
`canvas_edge_router`, `canvas_graph`, `flow_canvas_layout`, `flow_canvas_editor`
|
|
653
|
+
and `flow_canvas_css` check the DOM, the events, the geometry and the stylesheet
|
|
654
|
+
as text. In headless Edge, `demo/flow_canvas_probe.mjs` drives
|
|
655
|
+
`demo/flow_canvas.html` with real input:
|
|
656
|
+
- `elementFromPoint` at the centre of Undo, Arrange, Fit, a consumer verb, a
|
|
657
|
+
palette item, a node's header, a port, and the panel's name box, first field
|
|
658
|
+
and Remove step returned each one, and the panel was not covered.
|
|
659
|
+
- Every line began and ended within 1px of its ports' dots, measured.
|
|
660
|
+
- The loop's line back ran below both of its steps.
|
|
661
|
+
- A real pointer drag from the palette added exactly one step, under the
|
|
662
|
+
pointer, with a `dragstart` counter of 0. A drag ending off the canvas added
|
|
663
|
+
nothing.
|
|
664
|
+
- A real click-then-click connected two steps.
|
|
665
|
+
- A header drag moved its node, and a real Ctrl+Z put it back. Backspace removed
|
|
666
|
+
nothing, Delete removed the step, and the focus stayed on the canvas.
|
|
667
|
+
- At 700px the canvas took the editor's whole width.
|
|
668
|
+
|
|
669
|
+
### The outline editor
|
|
670
|
+
|
|
671
|
+
`createOutlineEditor` draws a logic flow as a list of blocks: *If … otherwise*,
|
|
672
|
+
*For each row* with its *Next row* foot, *At the same time* with its *Then wait
|
|
673
|
+
for every branch* foot, and a step's other ports (an error port, an empty-result
|
|
674
|
+
port) as arms under it. **The outline is the graph.** It reads the same
|
|
675
|
+
`{nodes, connections}` JSON the canvas edits, holds no second model, and every
|
|
676
|
+
edit is an operation on that graph. A graph opened and saved with no edit is the
|
|
677
|
+
same text, byte for byte, and node positions are kept and never used. A graph
|
|
678
|
+
that is not block-shaped is refused, never repaired: it opens read-only, as a
|
|
679
|
+
flat list in the order it runs, with the reason above it.
|
|
680
|
+
|
|
681
|
+
```js
|
|
682
|
+
import { createOutlineEditor, createStepCatalogue, createWidgetRegistry, TEMPLATE_REFERENCES } from '@flexdesk/flow';
|
|
683
|
+
|
|
684
|
+
const editor = createOutlineEditor(host, {
|
|
685
|
+
catalogue, widgets: createWidgetRegistry(), references: { template: TEMPLATE_REFERENCES },
|
|
686
|
+
blocks, // which roles are blocks, and their words (below)
|
|
687
|
+
values: async ({ stepId, field, before, loops }) => groupsFor(before, loops), // Insert a value
|
|
688
|
+
valuesNote: 'Only steps that always run before this one are listed.',
|
|
689
|
+
summarise: (node, type) => oneLine(node), // a row's line; {text, mono: true} for code
|
|
690
|
+
summariseReads: (node, type) => howLaterStepsRead(node), // the panel's id line
|
|
691
|
+
flowSettings: { schema, value, title, typeLabel, where, description, idLine },
|
|
692
|
+
actions: [{ id: 'publish', label: 'Publish', icon: 'publish', primary: true, run: () => publish() }],
|
|
693
|
+
slots: { toolbarEnd: (el) => {}, flowPanel: (el) => {}, stepPanel: (el, node) => {} },
|
|
694
|
+
initialFolds: saved, onFoldChange: (ids) => keep(ids),
|
|
695
|
+
onChange: ({ graph, flowSettings, text, action, key }) => autosave(text),
|
|
696
|
+
onSelect: ({ kind, id }) => {}, // kind: 'flow' or 'step'
|
|
697
|
+
readOnly: false, // or {reason}
|
|
698
|
+
history: { limit: 100, mergeMs: 1000 },
|
|
699
|
+
});
|
|
700
|
+
editor.load({ graph, flowSettings }); // a baseline: nothing to undo
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
| Method | Does |
|
|
704
|
+
|---|---|
|
|
705
|
+
| `load({graph, flowSettings}, {baseline = true})` | Replaces the content; a baseline clears both undo stacks and emits nothing |
|
|
706
|
+
| `getGraph()`, `serialise()`, `getFlowSettings()` | A copy of the graph; its byte-stable text; the flow's own settings |
|
|
707
|
+
| `setFindings(list)` | A finding with a `node_id` replaces that row's line with its message, in the error or warning tone, marks the row, and is drawn under its field; the others go to the strip, with *Go to it* |
|
|
708
|
+
| `setRunOverlay({steps, ports, banner} \| null)` | A run drawn on the outline (below) |
|
|
709
|
+
| `setReadOnly(false \| {reason})`, `setActionState(id, {disabled, reason, busy})`, `setStatus(text)` | A refused verb is disabled with its reason beside it |
|
|
710
|
+
| `select(id \| 'flow' \| null)`, `focus()`, `undo()`, `redo()`, `history`, `folds`, `setFolds(ids)` | `select` unfolds the blocks around the step |
|
|
711
|
+
| `describe()`, `refusal` | The outline as text (`describeOutline`), or why the graph could not be drawn as one |
|
|
712
|
+
| `destroy()` | Removes every listener, popover and ghost. It flushes nothing, because it holds nothing |
|
|
713
|
+
|
|
714
|
+
**The block mapping.** The consumer says which catalogue roles are blocks and
|
|
715
|
+
what their parts are called. Nothing here guesses a domain:
|
|
716
|
+
|
|
717
|
+
```js
|
|
718
|
+
const blocks = {
|
|
719
|
+
start: { role: 'start', label: 'When it runs' }, // the flow row
|
|
720
|
+
end: { role: 'end', entry: { label: 'End the run', sub: 'End' } },
|
|
721
|
+
step: { input: 'in', continue: 'out' }, // an ordinary step's two ports
|
|
722
|
+
branch: { role: 'branch', arms: { true: 'Then', false: 'Otherwise' },
|
|
723
|
+
entry: { label: 'If … otherwise', sub: 'Condition' }, note: 'Either arm may be empty.' },
|
|
724
|
+
fanout: { role: 'fanout', join: { role: 'join', type: 'merge', field: 'join',
|
|
725
|
+
foot: { all: 'Then wait for every branch', any: 'Then go on when the first finishes' } },
|
|
726
|
+
arm: 'Branch {n}', addArm: 'Add a branch', entry: { label: 'At the same time', sub: 'Parallel + Merge' } },
|
|
727
|
+
loop: { role: 'loop', ports: { entry: 'in', next: 'next', body: 'body', done: 'done' },
|
|
728
|
+
foot: 'Next row', skip: 'Go on with the next row', addInside: 'Add a step to the loop',
|
|
729
|
+
entry: { label: 'For each row', sub: 'Loop over rows' } },
|
|
730
|
+
arms: { error: { label: (step) => `If ${step.label} fails`, unconnected: 'fail',
|
|
731
|
+
setting: { label: 'If it fails', unconnected: 'Fail the run', after: 'timeout_seconds',
|
|
732
|
+
connected: (arm) => `Run the steps under “${arm}”` } } },
|
|
733
|
+
types: { 'http-request': { arms: { error: { label: 'If the request fails' } } } },
|
|
734
|
+
};
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
`unconnected` is what your runtime does with a port nothing is connected to,
|
|
738
|
+
`'fail'` or `'stop'`, and only you can say it. An ordinary step's other flow
|
|
739
|
+
outputs are its arms, in the order the type declares them. **Ports become
|
|
740
|
+
settings** in the panel. An arm is a choice between "Fail the run" (or "Stop
|
|
741
|
+
here") and "Run the steps under …", which connects or disconnects the port. An
|
|
742
|
+
arm inside a loop also chooses between carrying on with the next step and going
|
|
743
|
+
on with the next row. A parallel step carries its merge's *When the branches
|
|
744
|
+
finish*, written to the merge step's config.
|
|
745
|
+
|
|
746
|
+
**What "block-shaped" means.** From the start step, every step is placed exactly
|
|
747
|
+
once: as a step, a branch, a parallel closed by one merge, a loop whose body
|
|
748
|
+
ends at its Next, or an end. An arm ends by rejoining what follows its block,
|
|
749
|
+
by going on with the loop's next row, by ending, or by leaving its port
|
|
750
|
+
unconnected. Anything else is refused with a code and the step it names:
|
|
751
|
+
`outline_no_start`, `outline_two_starts`, `outline_unknown_type`,
|
|
752
|
+
`outline_unknown_port`, `outline_broken_line`, `outline_unreachable`,
|
|
753
|
+
`outline_shared_step`, `outline_jump_out`, `outline_two_continuations`,
|
|
754
|
+
`outline_parallel_unjoined`, `outline_merge_unpaired`, `outline_loop_shape`,
|
|
755
|
+
`outline_after_end`, `outline_cycle`. Where a branch's arms meet nowhere
|
|
756
|
+
(every arm but one ends), the arm that goes on holds the rest of its list. The
|
|
757
|
+
lines cannot say more than that, and the outline does not pretend they do.
|
|
758
|
+
|
|
759
|
+
**Edits.** "+" between rows (shown on hover and on focus) and the add rows open
|
|
760
|
+
the kit's step picker. It says where the step will land (*"inside For each new
|
|
761
|
+
application, after Check eligibility"*) and offers the blocks as blocks: *At
|
|
762
|
+
the same time* is one entry and two steps. A step's menu (its "…", a
|
|
763
|
+
right-click, or the ContextMenu key) moves it up, down or out, wraps it in a
|
|
764
|
+
block, duplicates, copies, cuts, pastes after, renames and removes it. An entry
|
|
765
|
+
or a verb that would leave a graph the outline cannot draw is shown greyed with
|
|
766
|
+
its reason: *"Move down — already last in For each new application"*. A step is
|
|
767
|
+
dragged by its grip, with pointer events and a ghost after 4 px, never
|
|
768
|
+
`draggable` and never `setPointerCapture`. Its drop line names the place
|
|
769
|
+
(*"Into Branch 2 · after Write a summary"*) or says why the place is refused.
|
|
770
|
+
Each edit is one history entry from a closed list: `flow:step:add`, `remove`,
|
|
771
|
+
`move`, `wrap`, `duplicate`, `paste`, `label` and `config`, `flow:arm`,
|
|
772
|
+
`flow:branch:add`, `flow:branch:remove` and `flow:settings`.
|
|
773
|
+
|
|
774
|
+
**Keys.** ↑ and ↓ move the selection, ← and → fold and unfold, and Enter opens
|
|
775
|
+
the settings at their first field. Alt+↑, Alt+↓ and Alt+← move a step, Ctrl+D
|
|
776
|
+
duplicates, Ctrl+C, Ctrl+X and Ctrl+V copy, cut and paste after the selection,
|
|
777
|
+
F2 renames, Delete removes, and Ctrl+Z and Ctrl+Y undo and redo. All of them are
|
|
778
|
+
bound on the editor's root. Backspace is never the editor's.
|
|
779
|
+
|
|
780
|
+
***Insert a value*.** A field's "{ }" calls your `values` provider with
|
|
781
|
+
`before`, the steps that always run before this one, nearest first
|
|
782
|
+
(`alwaysBefore`), and `loops`, the loops the step is inside, innermost first. A
|
|
783
|
+
step on the other arm of a condition is never in `before`, and neither is a
|
|
784
|
+
loop's body for a step after the loop.
|
|
785
|
+
|
|
786
|
+
**A run drawn on the outline.** `setRunOverlay({steps: {[id]: {state, line,
|
|
787
|
+
tone}}, ports: {[id]: {[port]: {taken, count}}}, banner})` puts a pill beside
|
|
788
|
+
each step. The pill shows `line` as you give it, toned by `state`
|
|
789
|
+
(`completed`, `failed`, `skipped`, `running`, `cancelled`, `not-reached`). Each
|
|
790
|
+
arm says *taken*, *not taken* or a count, an untaken arm and its steps are
|
|
791
|
+
dimmed, a loop's *Next row* shows its count (`ports[loop].next.count`), and
|
|
792
|
+
your banner is shown above the list. It draws only what you pass, and it
|
|
793
|
+
rebuilds no field in the panel. A redraw you did not ask for (findings, a run
|
|
794
|
+
refresh, a read-only switch) waits while a press is held in the editor, because
|
|
795
|
+
a browser fires a click only when the press and the release land on the same
|
|
796
|
+
element.
|
|
797
|
+
|
|
798
|
+
**The pure parts** are exported for a consumer that reads or checks an outline
|
|
799
|
+
without drawing one. `outlineFromGraph(graph, catalogue, blocks)` returns
|
|
800
|
+
`{ok: true, tree}` or `{ok: false, code, message, node_id}`, and
|
|
801
|
+
`graphFromOutline(tree)` is its inverse (`outlineGraphsEqual(a, b)` compares
|
|
802
|
+
two graphs as sets). `describeOutline(tree)` gives the tree as text.
|
|
803
|
+
`createOutlineOperations({catalogue, blocks, references})` gives every edit as
|
|
804
|
+
a function from a graph to `{ok, graph, select}` or `{ok: false, reason}`. Also
|
|
805
|
+
exported: `createOutlineBlocks`, `outlineRunOrder`, `outlineFlatOrder`,
|
|
806
|
+
`indexOutline`, `openOutlineStepMenu`, `OUTLINE_STRINGS`, `OUTLINE_ACTIONS` and
|
|
807
|
+
`OUTLINE_INDENT` (22 px a level, which the stylesheet also uses).
|
|
808
|
+
|
|
809
|
+
**What it does not do.** It does not fetch, save, validate or start a timer
|
|
810
|
+
that writes. Your validator's findings are drawn, never made. The folds are
|
|
811
|
+
yours to keep (`initialFolds`, `onFoldChange`).
|
|
812
|
+
|
|
813
|
+
**Where each claim was checked.** The round trip is tested over a corpus
|
|
814
|
+
(`tests/fixtures/flow_outline_corpus.json`). Every block-shaped graph is
|
|
815
|
+
recognised as the tree the corpus states and written back equal as sets (I1).
|
|
816
|
+
Opened and saved with no edit, it is the same text (I2). Random sequences of
|
|
817
|
+
operations keep it block-shaped (I3). Every refusal names a code and a step and
|
|
818
|
+
leaves the graph untouched (I4). `tests/flow_outline_editor.test.mjs` mounts
|
|
819
|
+
the editor in jsdom, and `tests/flow_outline_css.test.mjs` checks that every
|
|
820
|
+
class the editor sets has a rule and every rule names a class it sets. jsdom
|
|
821
|
+
computes no layout and starts no drag, so `demo/flow_outline_probe.mjs` drives
|
|
822
|
+
`demo/flow_outline.html` in headless Edge with real input. `elementFromPoint`
|
|
823
|
+
at the centre of Undo, an action, Fold all, the find box, the flow row, a row,
|
|
824
|
+
its menu, its "+" (with its row hovered), an add row, the panel's first field,
|
|
825
|
+
a port drawn as a setting and the strip's *Go to it* returned each one. A "+"
|
|
826
|
+
whose row was not hovered caught no press, and a level measured 22 px. The step
|
|
827
|
+
picker and the step menu opened inside the viewport on a real press, real keys
|
|
828
|
+
filtered and chose, and Escape gave the focus back. Real Alt+↑, Ctrl+Z, Ctrl+D
|
|
829
|
+
and Delete edited the flow, and Backspace reached the window unprevented. A real
|
|
830
|
+
grip drag drew its ghost and a drop line naming the place, moved the step as
|
|
831
|
+
one edit and started no native drag (a `dragstart` counter at 0). A refused
|
|
832
|
+
place said why and moved nothing, and Escape cancelled the drag.
|
|
833
|
+
|
|
834
|
+
### The lane editor
|
|
835
|
+
|
|
836
|
+
`createLaneEditor(host, options)` draws a data flow as lanes, left to right.
|
|
837
|
+
Every source starts a lane, and each step follows the one before it. A join
|
|
838
|
+
brings another lane in from below, and its line is laid out for you. Cards sit
|
|
839
|
+
where the layout puts them: **nothing is dragged and no line is drawn by
|
|
840
|
+
hand.** A step is added with the "+" on a card's right tip, which splices it in
|
|
841
|
+
between the card and what the card led to (`A → new → B`). *Add a source* starts
|
|
842
|
+
a new lane. A join's other lanes are settings (*Joined with*), not lines. Under
|
|
843
|
+
the lanes are a tab per step and a dock: the kit's settings panel on the left,
|
|
844
|
+
and on the right what the step gives — **Preview**, **Columns** and, for a sink,
|
|
845
|
+
**Would be rejected**. A toolbar toggle, **Steps show: The preview | The last
|
|
846
|
+
run**, picks what each card's status line says.
|
|
847
|
+
|
|
848
|
+
```js
|
|
849
|
+
import { createLaneEditor, createStepCatalogue, createWidgetRegistry, PARAMETER_REFERENCES } from '@flexdesk/flow';
|
|
850
|
+
|
|
851
|
+
const editor = createLaneEditor(host, {
|
|
852
|
+
catalogue: createStepCatalogue(types, { categories }), // roles: source | transform | operation | sink
|
|
853
|
+
widgets: createWidgetRegistry(),
|
|
854
|
+
references: { parameter: PARAMETER_REFERENCES },
|
|
855
|
+
summarise: (step, type) => '…', // a card's second line
|
|
856
|
+
preview: async ({ pipeline, text, parameters, signal }) => ({ nodes: { [id]: {
|
|
857
|
+
status, rows, total, columns: [{ name, type }], head: [[…]], caption, rejects, error } } }),
|
|
858
|
+
describe: async ({ pipeline, text, parameters, signal }) => ({ nodes: { [id]: {
|
|
859
|
+
columns: [{ name, type, origin, change }] } } }),
|
|
860
|
+
lastRun: async ({ pipeline, signal }) => runOverlay, // asked when Steps show turns to The last run
|
|
861
|
+
actions: [{ id: 'publish', label: 'Publish', primary: true, run }],
|
|
862
|
+
onChange: ({ pipeline, text, action, key }) => save(text), // after every edit, undo and redo
|
|
863
|
+
});
|
|
864
|
+
editor.load({ graph: pipeline });
|
|
865
|
+
```
|
|
866
|
+
|
|
867
|
+
It edits the data flow's JSON — `{nodes: [{id, type, label?, config, position?}],
|
|
868
|
+
connections: [{id, sourceId, sourcePort, targetId, targetPort}], parameters:
|
|
869
|
+
{name: {type, default?, description?}}}` — with two rules added. A step's name
|
|
870
|
+
is a top-level `label`, never a key inside `config`. Connection ids are
|
|
871
|
+
deterministic: `c1`, `c2`, … the lowest free number. `serialisePipeline` is
|
|
872
|
+
byte-stable, so a flow opened and saved with no edit is the same text. Keys the
|
|
873
|
+
editor does not know are kept, and a node's `position` is kept and never used.
|
|
874
|
+
**A step's ports keep their declared order**: the first FLOW input is the lane
|
|
875
|
+
input, and every further input takes another lane. The first output is the
|
|
876
|
+
continuation.
|
|
877
|
+
|
|
878
|
+
**The layout** is `layoutLanes(pipeline, catalogue)` and `placeLanes(layout,
|
|
879
|
+
size)`. Both are pure, and the wires are computed from the grid, never measured
|
|
880
|
+
from the page. A step belongs to the lane that feeds its lane input, not to
|
|
881
|
+
whichever lane reaches it first. An output that feeds two steps' lane inputs
|
|
882
|
+
forks a new lane below. A feeder lane is moved right until its last card sits
|
|
883
|
+
one column before the join it feeds, and never so far that the join moves.
|
|
884
|
+
Under each lane come its feeders, then its forks. A wire that would run through a card that is not one of its ends (a feeder that goes on past its feed, a fork drawn under a feeder) goes round by the gutters, the gaps between columns and between lanes, which hold no card. A step two lines feed on one
|
|
885
|
+
input (`lanes_two_inputs`), a cycle (`lanes_cycle`) or two steps with one id
|
|
886
|
+
(`lanes_duplicate_step`) cannot be drawn honestly as lanes. Such a flow opens
|
|
887
|
+
read only with the sentence, is drawn as well as it can be, and is never
|
|
888
|
+
repaired. The sizes are `LANE_GEOMETRY`: `regular` is 200 × 62 (the editor),
|
|
889
|
+
`small` is 160 × 50 (two lines a card, for a narrower host) and `strip` is
|
|
890
|
+
92 × 26 (`compact`). The pure edits — splice, add a source, remove, set an
|
|
891
|
+
input, the input candidates, parameters — are `lanePipeline`.
|
|
892
|
+
|
|
893
|
+
| Option | Meaning |
|
|
894
|
+
|---|---|
|
|
895
|
+
| `catalogue` | The kit's step catalogue (or a list of types and `categories`) |
|
|
896
|
+
| `widgets`, `references`, `services`, `strings` | As for the kit's panel. `references.parameter` decides how the parameters strip spells a reference |
|
|
897
|
+
| `values` | *Insert a value*: `({pipeline, stepId, field, key, upstream, parameters}) => groups`. `upstream` is every step whose output reaches this one, nearest first |
|
|
898
|
+
| `summarise(step, type)`, `summariseReads(step, type)` | A card's second line; the panel's id line |
|
|
899
|
+
| `preview`, `describe` | The providers (above). Absent, the dock has no Preview, or no Columns |
|
|
900
|
+
| `previewDelayMs` | The debounce, 800 by default |
|
|
901
|
+
| `lastRun` | Asked once, when *Steps show* first turns to *The last run* and no overlay is set |
|
|
902
|
+
| `flowSettings` | `{schema, value}`: the flow's own settings, under a first tab, *The flow* |
|
|
903
|
+
| `readOnly` | `false`, or `{reason}`, drawn above the editor |
|
|
904
|
+
| `actions` | Toolbar verbs after Undo and Redo: `[{id, label, icon?, primary?, run}]` |
|
|
905
|
+
| `slots` | `toolbarStart(el)`, `toolbarEnd(el)`, `flowPanel(el)`, `stepPanel(el, step)` |
|
|
906
|
+
| `size` | `'regular'` or `'small'` |
|
|
907
|
+
| `stepTabs`, `parameters` | `false` leaves out the step tabs or the parameters strip |
|
|
908
|
+
| `height` | `'fill'` (the default: the editor fills its host, which must have a height) or `'auto'` (the editor flows, and the dock is `dockHeight`, 360 by default) |
|
|
909
|
+
| `compact` | `true` (or `{size: 'small'}`): the lanes alone, read only, nothing to press. This is what a step that runs a data flow shows as *What it does, left to right* |
|
|
910
|
+
| `parameterTypes` | The parameter types a parameter may be: `text`, `number`, `integer`, `boolean`, `date` by default |
|
|
911
|
+
| `select` | The step chosen on load (the first step, left to right, by default) |
|
|
912
|
+
| `history` | `{limit, mergeMs}` |
|
|
913
|
+
| `onChange`, `onSelect({kind, id})`, `onStepsShow(mode)` | Callbacks |
|
|
914
|
+
|
|
915
|
+
| Method | Does |
|
|
916
|
+
|---|---|
|
|
917
|
+
| `load({graph, flowSettings}, {baseline = true})` | Replaces the content. A baseline clears both undo stacks, and a preview is asked for at once |
|
|
918
|
+
| `getGraph()`, `serialise()`, `getFlowSettings()` | A copy of the flow; its byte-stable text |
|
|
919
|
+
| `setFindings(list)` | On the card (its status line becomes the message), under the field, and in the strip with *Go to it* |
|
|
920
|
+
| `setRunOverlay({steps: {[id]: {state, line, tone}}, banner})` | The last run, shown when *Steps show* is *The last run*. `line` is drawn as given |
|
|
921
|
+
| `setStepsShow('preview' \| 'run')`, `stepsShow` | — |
|
|
922
|
+
| `setReadOnly(false \| {reason})`, `readOnly` | — |
|
|
923
|
+
| `setActionState(id, {disabled, reason, busy})`, `setStatus(text)` | A refused verb shows its reason beside it |
|
|
924
|
+
| `select(id \| 'flow' \| null)`, `selected`, `focus()` | — |
|
|
925
|
+
| `undo()`, `redo()`, `history` | Over whole snapshots. The actions are `LANE_ACTIONS` (`flow:step:add`, `:remove`, `:label`, `:config`, `flow:join:set`, `flow:source:add`, `flow:parameter:add`, `:change`, `:remove`, `flow:settings`) |
|
|
926
|
+
| `refreshPreview()` | Asks the providers now, without the debounce |
|
|
927
|
+
| `inputColumns(stepId, port?)` | The columns of the frame a step reads, from the last description, else the last preview. The `upstream-column(s)` widgets read the same |
|
|
928
|
+
| `layout`, `placed` | The current layout and its coordinates |
|
|
929
|
+
| `destroy()` | Removes every listener, popover and table, and aborts the preview in flight |
|
|
930
|
+
|
|
931
|
+
**The preview** is one request for the whole flow to each provider. It is sent
|
|
932
|
+
800 ms after the last edit and once after a load. A rename does not send one,
|
|
933
|
+
because it changes no row. Every request carries a sequence number and an
|
|
934
|
+
`AbortSignal`. A newer edit aborts the request in flight, and an answer that is
|
|
935
|
+
not the newest is dropped even when it arrives. A failure is shown as the
|
|
936
|
+
provider's own sentence in the dock, and nothing else changes. The rows are a
|
|
937
|
+
FlexDesk `DataTable` in a box with a definite height. How many rows a preview
|
|
938
|
+
reads, as whom it reads them and that it writes nothing are all the provider's
|
|
939
|
+
business; the editor knows only what it is handed.
|
|
940
|
+
|
|
941
|
+
**Keys.** These are the kit's, on the editor's root. ← → ↑ ↓ move between cards
|
|
942
|
+
and between the step tabs. Enter opens a step's settings on their first field,
|
|
943
|
+
and Escape goes back to the card. Delete removes the step and F2 renames it.
|
|
944
|
+
Ctrl/⌘+Z and Ctrl/⌘+Y undo and redo, and are stopped at the editor even inside
|
|
945
|
+
a field. The context-menu key or a right press opens a card's menu: *Open its
|
|
946
|
+
settings*, *Add a step after it*, *Remove step*. Backspace is never the
|
|
947
|
+
editor's. The cards are one Tab stop, the chosen one, and its "+" comes next in
|
|
948
|
+
the Tab order. The "+" shows on hover, on focus and on the chosen card, never
|
|
949
|
+
on hover alone.
|
|
950
|
+
|
|
951
|
+
**What it does not do.** It does not fetch, save, validate, propagate a schema,
|
|
952
|
+
coerce a value or run anything. Steps are never dragged and lines are never
|
|
953
|
+
drawn by hand. The editor does not draw a page trail, a breadcrumb or a "you
|
|
954
|
+
are editing the shared flow" banner either: a host draws those around it.
|
|
955
|
+
|
|
956
|
+
**Where each claim was checked.** The layout, the JSON, the edits and the
|
|
957
|
+
preview runner are pure, and `tests/flow_lanes_layout.test.mjs`,
|
|
958
|
+
`flow_lanes_pipeline.test.mjs` and `flow_lanes_preview.test.mjs` test them under
|
|
959
|
+
plain node. The layout suite reproduces the mock and shows that the earlier
|
|
960
|
+
layout (EcoSim's `buildLanes`, copied verbatim) puts the join in the feeder's
|
|
961
|
+
lane when the feeder is listed first. The preview suite has a provider that
|
|
962
|
+
answers out of order, and the stale answer is shown to be dropped.
|
|
963
|
+
`flow_lanes_editor.test.mjs` mounts the editor in jsdom: choosing a card
|
|
964
|
+
rebuilds no node, every edit is one undo entry, and a flow that cannot be drawn
|
|
965
|
+
is never written. `flow_lanes_css.test.mjs` holds the classes and the rules to
|
|
966
|
+
each other and the sheet's card sizes to `LANE_GEOMETRY`. The rest was checked
|
|
967
|
+
in headless Edge with real input (`node demo/flow_lanes_probe.mjs`, which opens
|
|
968
|
+
`demo/flow_lanes.html` at all three sizes). In a laid-out page every wire
|
|
969
|
+
starts at its card's tip and ends in the next card's notch, and the join's line
|
|
970
|
+
and its port dot meet the join's bottom centre. `elementFromPoint` at the centre
|
|
971
|
+
of Undo, a card, a "+", *Add a source*, a step tab, *Steps show*, a parameter,
|
|
972
|
+
the panel's first field, its name, *Remove step*, a dock tab and a preview cell
|
|
973
|
+
returns each one, and the settings are covered by nothing. The preview's
|
|
974
|
+
`DataTable` draws its rows with a height, and every card's words stay inside
|
|
975
|
+
the card. A real press on "+" opens the picker inside the viewport with its
|
|
976
|
+
search focused, and real keys filter it and add the step. A real drag across a
|
|
977
|
+
card starts no native drag (a `dragstart` counter at 0) and moves nothing. Real
|
|
978
|
+
arrows, Delete, Ctrl+Z and Backspace do what is said above, and three real
|
|
979
|
+
keystrokes ask the preview once.
|
|
980
|
+
|
|
328
981
|
## The host port
|
|
329
982
|
|
|
330
983
|
FlexDesk never touches `pywebview`, `fetch`, `localStorage` or the filesystem. It
|