node-red-contrib-join-wait 0.6.1 → 0.6.3
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/CHANGELOG.md +184 -160
- package/README.md +5 -0
- package/join-wait.html +328 -159
- package/package.json +3 -4
package/CHANGELOG.md
CHANGED
|
@@ -4,178 +4,202 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.6.3] - 2026-05-09
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Open example flows button** is finally working. The 0.6.2 fix
|
|
12
|
+
invoked the import action via `RED.tray.close(cb)`'s callback, but
|
|
13
|
+
that callback fires before the `editor:close` event — so
|
|
14
|
+
clipboard.js's `disabled` guard was still set when the action ran,
|
|
15
|
+
and the dialog never appeared. Now listens for `editor:close`
|
|
16
|
+
explicitly and defers the action one tick past the synchronous
|
|
17
|
+
listener chain that clears the flag. Adds a guard against fast
|
|
18
|
+
double-clicks and a feature-detect fallback to
|
|
19
|
+
`core:show-import-dialog` for Node-RED < 3.1.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **`engines.node` lowered to `>=18.5`** to match the Node-RED 4.x
|
|
24
|
+
minimum (per the contrib-node packaging spec). CI matrix gains
|
|
25
|
+
Node 18 so the claim is verified, not asserted.
|
|
26
|
+
- **Editor template updated to `const`/`let` and arrow functions**.
|
|
27
|
+
Pure stylistic refactor of `join-wait.html`'s IIFE; no behaviour
|
|
28
|
+
change. `function` is preserved where Node-RED binds `this`
|
|
29
|
+
(validators, `oneditprepare`/`save`/`resize`, jQuery `.each`/`.map`
|
|
30
|
+
callbacks).
|
|
31
|
+
- Dropped the `node-red.examples` field from `package.json` — not
|
|
32
|
+
part of the packaging spec; examples auto-discover from
|
|
33
|
+
`examples/`.
|
|
34
|
+
|
|
35
|
+
## [0.6.2] - 2026-05-09
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **Open example flows button** was a no-op. Now invokes
|
|
40
|
+
`core:show-examples-import-dialog` directly, opening the dialog as a
|
|
41
|
+
modal above the edit tray.
|
|
42
|
+
- **Wait paths editableList overlapped the form rows below it** for
|
|
43
|
+
long lists. Resized via the canonical Node-RED pattern (numeric
|
|
44
|
+
height + `oneditresize` filling the tray, no upper clamp); re-flows
|
|
45
|
+
on the Advanced `<details>` toggle.
|
|
46
|
+
- **Persist store admin call** now prefixes `RED.settings.apiRootUrl`,
|
|
47
|
+
so it works under a custom `httpAdminRoot` or behind a reverse
|
|
48
|
+
proxy.
|
|
49
|
+
- **Help text** no longer references the file-based persistence path
|
|
50
|
+
removed in 0.6.0; corrected to describe the context-store backend.
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **Bulk paste in path lists** — paste newline-separated text into any
|
|
55
|
+
Wait paths or Reset paths row to fill the row + append one row per
|
|
56
|
+
remaining line.
|
|
57
|
+
- **Richer inline validation** — output preview reflects
|
|
58
|
+
`pathTopicType` and shows repeat counts as `path_1 (×2): …`;
|
|
59
|
+
duplicate Reset paths and overlap between Wait/Reset paths are
|
|
60
|
+
flagged inline; Timeout accepts fractional values.
|
|
61
|
+
- Tests for the `/join-wait/stores` admin endpoint and drift fences
|
|
62
|
+
in `editor_spec.js` so the help text never re-acquires legacy keys.
|
|
63
|
+
|
|
64
|
+
### Changed
|
|
65
|
+
|
|
66
|
+
- Persist store row hides when Preserve queue is off; output-preview
|
|
67
|
+
and Advanced summary use Node-RED CSS variables (theme-aware);
|
|
68
|
+
Timeout field widths cleaned up.
|
|
69
|
+
|
|
70
|
+
## [0.6.1] - 2026-05-08
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- Shipped example flows wouldn't import — inject nodes were missing
|
|
75
|
+
the standard top-level `topic`, `payload`, `repeat`, `crontab`,
|
|
76
|
+
`once`, and `onceDelay` fields. All five examples regenerated in
|
|
77
|
+
the canonical inject format.
|
|
78
|
+
- First attempt at fixing the **Open example flows** button (closed
|
|
79
|
+
the edit tray before invoking the import dialog) — didn't reliably
|
|
80
|
+
surface; see 0.6.2 for the working fix.
|
|
81
|
+
|
|
7
82
|
## [0.6.0] - 2026-05-08
|
|
8
83
|
|
|
9
84
|
### Added
|
|
10
85
|
|
|
11
|
-
- **Modern
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
- **
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
(`title` attributes) for every form row.
|
|
33
|
-
- **Inline output-shape preview.** Under Wait paths, a small monospace
|
|
34
|
-
line shows what the merged success message looks like
|
|
35
|
-
(`→ msg.topic = { path_1: …, path_2: … }`), updating as the list
|
|
36
|
-
changes.
|
|
37
|
-
- **Smart timeout warning.** When the resolved timeout is under 50 ms,
|
|
38
|
-
a yellow tip surfaces the README guidance about padding evaluation
|
|
39
|
-
overhead.
|
|
40
|
-
- **Inline jsonata validation on Group by.** When the field type is
|
|
41
|
-
`jsonata`, the expression is parsed at edit-time and the field marks
|
|
42
|
-
invalid before the first message even arrives.
|
|
43
|
-
- **Quick "Open example flows" button** in the editor opens the import
|
|
44
|
-
dialog directly.
|
|
45
|
-
- Path-row placeholders changed from generic `path name` to a worked
|
|
46
|
-
example (`e.g. sensor_1`, `e.g. abort`) so empty rows hint at intent.
|
|
47
|
-
- **Status-indicator error states.** Validation, regex compile, and
|
|
48
|
-
correlator-evaluation failures now show a red ring on the node with a
|
|
49
|
-
short reason in addition to the usual `node.error` log entry.
|
|
50
|
-
- **Unhandled-rejection guard** around the async input handler so a future
|
|
51
|
-
bug or upstream throw can't escape without being logged.
|
|
52
|
-
- End-to-end smoke test for **all five** shipped example flows, plus a
|
|
53
|
-
static config-shape sanity test on `join-wait.html` to catch drift
|
|
54
|
-
between the editor defaults and the runtime fields.
|
|
55
|
-
- **Advanced settings** are collapsed under a single `<details>` group so
|
|
56
|
-
the editor presents the common options first.
|
|
57
|
-
- **`msg.reset`** — sending `msg.reset = true` silently drains the queue
|
|
58
|
-
for the current correlation group with no output.
|
|
59
|
-
- **`node.status()`** indicator showing the number of queued messages.
|
|
60
|
-
- **Examples** — the package now ships three ready-made flows
|
|
61
|
-
(`Quickstart`, `Correlation`, `Reset paths`) discoverable via
|
|
62
|
-
**Menu → Import → Examples → join-wait**.
|
|
63
|
-
- **Per-instance persistence store** — new `Persist store` field selects a
|
|
64
|
-
named context store from `settings.js` for restart-survival.
|
|
65
|
-
- Unit-test specs for the `lib/config`, `lib/matcher`, and `lib/persist`
|
|
66
|
-
modules.
|
|
86
|
+
- **Modern `(msg, send, done)` input handler** — proper async-message
|
|
87
|
+
tracking and graceful shutdown (the old auto-completion fired
|
|
88
|
+
before awaited work resolved).
|
|
89
|
+
- **Editor UX overhaul** — Wait paths and Reset paths now use
|
|
90
|
+
`editableList` (add/remove/reorder rows; inline validation for
|
|
91
|
+
empty rows + invalid regex; Enter adds the next). Inline output
|
|
92
|
+
preview, smart short-timeout warning, jsonata-validity check on
|
|
93
|
+
Group by, tooltips on every label, an **Open example flows**
|
|
94
|
+
shortcut, and an **Advanced** collapsible. Field labels renamed
|
|
95
|
+
for clarity.
|
|
96
|
+
- **Persist store dropdown** — new `/join-wait/stores` admin route
|
|
97
|
+
exposes the configured context stores; the editor renders a typo-proof
|
|
98
|
+
`<select>`.
|
|
99
|
+
- **Smarter `node.status()`** — single-group nodes show progress
|
|
100
|
+
(`2/3 received`); validation / regex-compile / correlator-evaluation
|
|
101
|
+
failures show a red ring with a short reason.
|
|
102
|
+
- **Five shipped example flows** — Quickstart, Correlation, Reset,
|
|
103
|
+
Regex, Exact order — discoverable via **Menu → Import → Examples
|
|
104
|
+
→ join-wait**.
|
|
105
|
+
- **`msg.reset`** silently drains the queue for the current
|
|
106
|
+
correlation group.
|
|
67
107
|
|
|
68
108
|
### Changed
|
|
69
109
|
|
|
70
|
-
- **
|
|
71
|
-
|
|
72
|
-
`
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
-
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
-
|
|
88
|
-
|
|
89
|
-
Removes a class of subtle defaults-bug for non-editor-built flows.
|
|
90
|
-
- **`countPathsAnyOrder`** now uses a `Set` to track used indices
|
|
91
|
-
(O(1) check vs the previous O(n) `Array.indexOf`).
|
|
92
|
-
- **Comment around `flushOnMsgComplete`** documents the deliberately
|
|
93
|
-
partial behavior: `msg.complete` short-circuits a partial queue but
|
|
94
|
-
never overrides a successful match.
|
|
95
|
-
- **`node:` prefix on core imports** in test files (`node:fs`,
|
|
96
|
-
`node:os`, `node:path`).
|
|
97
|
-
- **Expired output now uses the configured `Path field`** for the merged
|
|
98
|
-
data (matching the success output). Previously hardcoded to `msg.paths`
|
|
99
|
-
even when a different `pathTopic` was configured. Existing flows that
|
|
100
|
-
use the default `paths` setting see no change.
|
|
101
|
-
- **Persistence rewritten on top of Node-RED's context store** (replacing
|
|
102
|
-
the `node-persist` singleton, which raced when multiple `join-wait` nodes
|
|
103
|
-
were deployed in the same flow). Default in-memory store keeps queues
|
|
104
|
-
across deploys; a configured persistent store keeps them across full
|
|
105
|
-
restarts.
|
|
106
|
-
- **`Preserve queue` defaults to on** for new nodes. Partial joins now
|
|
107
|
-
survive a redeploy out of the box. Existing 0.5.x flows keep their
|
|
108
|
-
original `persistOnRestart: false` until re-saved.
|
|
109
|
-
- **`Persist store` is no longer required** for restart persistence.
|
|
110
|
-
If `localfilesystem` (or similar) is set as the **default** context
|
|
111
|
-
store in `settings.js`, every join-wait node uses it automatically.
|
|
112
|
-
- **Auto-pick of a persistent named store.** When `Preserve queue` is
|
|
113
|
-
on, `Persist store` is empty, and the configured default store is
|
|
114
|
-
memory, the node auto-selects the first non-memory named store from
|
|
115
|
-
`settings.js` (with a `node.log` line so it's discoverable). If the
|
|
116
|
-
user has both `default: memory` and (say) `file: localfilesystem`
|
|
117
|
-
configured, they get restart persistence for free without setting
|
|
118
|
-
`Persist store` on every node. Setting `Persist store` explicitly
|
|
119
|
-
always wins.
|
|
120
|
-
- **British → American spellings.** `normalisePaths` → `normalizePaths`,
|
|
121
|
-
`serialisable` → `serializable`, `behaviour` → `behavior` in code +
|
|
122
|
-
docs for consistency with the wider JS / npm ecosystem.
|
|
123
|
-
- **`msg.pathsToWait`, `msg.pathsToExpire`, `msg.useRegex` are now one-shot
|
|
124
|
-
overrides** — they apply only to the current message instead of
|
|
125
|
-
permanently mutating the node's stored config.
|
|
126
|
-
- **Code split** into `lib/config.js`, `lib/matcher.js`, and `lib/persist.js`
|
|
127
|
-
— `join-wait.js` is now a thin orchestrator. The input handler is broken
|
|
128
|
-
into discrete named phases for readability.
|
|
129
|
-
- **`mapPayload` no longer mutates the caller's `pathTopic` object** —
|
|
130
|
-
incoming messages are left untouched.
|
|
131
|
-
- **jsonata bumped to v2** (`evaluate()` is now properly awaited).
|
|
132
|
-
- **Node-RED bumped to ≥ 3.0**, **Node.js ≥ 18**.
|
|
133
|
-
- **`engines.node`, `files`** added to `package.json`; deprecated
|
|
134
|
-
`licenses` array removed.
|
|
135
|
-
- Source labels in the editor renamed for clarity:
|
|
136
|
-
- "Paths topic" → **Path field**
|
|
137
|
-
- "Paths (Wait)" / "Paths (Expire)" → **Wait paths** / **Reset paths**
|
|
138
|
-
- "Sequence order" → **Match order**
|
|
139
|
-
- "Base message" → **Output base**
|
|
140
|
-
- "Merged data" → **Merge values**
|
|
141
|
-
- README rewritten — quickstart first, technical details after.
|
|
110
|
+
- **Persistence rewritten on top of Node-RED's context store**,
|
|
111
|
+
replacing the `node-persist` singleton (which raced when multiple
|
|
112
|
+
`join-wait` nodes shared the flow). Default in-memory store keeps
|
|
113
|
+
queues across deploys; a configured persistent store keeps them
|
|
114
|
+
across full restarts. **Preserve queue** defaults to on; the node
|
|
115
|
+
auto-picks the first persistent named store when the default is
|
|
116
|
+
memory.
|
|
117
|
+
- **`msg.pathsToWait`, `msg.pathsToExpire`, `msg.useRegex`** are now
|
|
118
|
+
one-shot overrides — they apply only to the current message
|
|
119
|
+
instead of permanently mutating the node's stored config.
|
|
120
|
+
- **Expired output uses the configured Path field** for the merged
|
|
121
|
+
data (matched the success output). Previously hardcoded to
|
|
122
|
+
`msg.paths`.
|
|
123
|
+
- **Code split** into `lib/config.js`, `lib/matcher.js`,
|
|
124
|
+
`lib/persist.js`; `findAllPaths{AnyOrder,ExactOrder}` return a
|
|
125
|
+
`{matched, keep}` shape; `drainQueue` takes options; internal
|
|
126
|
+
renames for clarity (config keys unchanged for back-compat).
|
|
127
|
+
- jsonata bumped to v2 (proper async `evaluate()`); Node-RED ≥ 3.0,
|
|
128
|
+
Node.js ≥ 20.
|
|
142
129
|
|
|
143
130
|
### Fixed
|
|
144
131
|
|
|
145
|
-
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
Node-RED shutdown.
|
|
152
|
-
- Empty `<b></b>` tag in the help text.
|
|
153
|
-
- Stale `ignoreUnmatched` references in older example flows.
|
|
132
|
+
- `node.error` / `node.warn` correctly pass the originating `msg`
|
|
133
|
+
(previously passed as `[msg, null]` since 0.5.x — Catch nodes
|
|
134
|
+
downstream now receive the real message).
|
|
135
|
+
- Close handler always calls `done` even when the context-store
|
|
136
|
+
write rejects, so persistence failures don't hold up shutdown.
|
|
137
|
+
- `mapPayload` no longer mutates the caller's `pathTopic` object.
|
|
154
138
|
- Possible race when multiple `join-wait` nodes shared the global
|
|
155
|
-
`node-persist` singleton (now
|
|
139
|
+
`node-persist` singleton (now an isolated context-store entry per
|
|
156
140
|
node).
|
|
157
141
|
|
|
158
142
|
### Tooling
|
|
159
143
|
|
|
160
|
-
- GitHub Actions CI replaces Travis (matrix on Node 20/22/24
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
-
|
|
173
|
-
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
## [0.5.
|
|
180
|
-
|
|
181
|
-
|
|
144
|
+
- GitHub Actions CI replaces Travis (matrix on Node 20/22/24);
|
|
145
|
+
publish job gated on lint + format + spellcheck + test matrix.
|
|
146
|
+
ESLint v9 (flat config), Prettier v3, c8 (replaces nyc), cspell
|
|
147
|
+
(replaces `markdown-spellcheck`); Dependabot for monthly npm + GHA
|
|
148
|
+
updates. Test suite expanded from 32 to 102 cases.
|
|
149
|
+
|
|
150
|
+
## [0.5.3] - 2021-10-31
|
|
151
|
+
|
|
152
|
+
- Update dependencies.
|
|
153
|
+
|
|
154
|
+
## [0.5.2] - 2021-02-02
|
|
155
|
+
|
|
156
|
+
- Retain state across restart
|
|
157
|
+
([#7](https://github.com/dxdc/node-red-contrib-join-wait/issues/7)).
|
|
158
|
+
|
|
159
|
+
## [0.5.1] - 2021-01-05
|
|
160
|
+
|
|
161
|
+
- More automated testing and code coverage.
|
|
162
|
+
|
|
163
|
+
## [0.5.0] - 2020-12-17
|
|
164
|
+
|
|
165
|
+
- Expire earlier for "any order" mode
|
|
166
|
+
([#4](https://github.com/dxdc/node-red-contrib-join-wait/issues/4)).
|
|
167
|
+
|
|
168
|
+
## [0.4.5] - 2020-12-15
|
|
169
|
+
|
|
170
|
+
- Add automated tests; address several minor issues uncovered by
|
|
171
|
+
testing; refactoring and readability changes.
|
|
172
|
+
|
|
173
|
+
## [0.4.0] - 2020-12-14
|
|
174
|
+
|
|
175
|
+
- Reworked logic for exact-order mode
|
|
176
|
+
([#1](https://github.com/dxdc/node-red-contrib-join-wait/issues/1)).
|
|
177
|
+
- Support for duplicate path names
|
|
178
|
+
([#2](https://github.com/dxdc/node-red-contrib-join-wait/issues/2)).
|
|
179
|
+
- Show all unmatched paths with individual warnings.
|
|
180
|
+
- Minor changes to config UI and defaults.
|
|
181
|
+
|
|
182
|
+
## [0.3.5] - 2020-05-26
|
|
183
|
+
|
|
184
|
+
- Enforce unique path names.
|
|
185
|
+
- Regex-based path matching.
|
|
186
|
+
|
|
187
|
+
## [0.3.4] - 2020-01-17
|
|
188
|
+
|
|
189
|
+
- Don't reuse the `msg` variable.
|
|
190
|
+
|
|
191
|
+
## [0.3.3] - 2020-01-17
|
|
192
|
+
|
|
193
|
+
- Fix bug with paths / `clearTimeout`.
|
|
194
|
+
- Minor code optimizations; add Example 3 to documentation.
|
|
195
|
+
|
|
196
|
+
## [0.3.2] - 2020-01-16
|
|
197
|
+
|
|
198
|
+
- `msg.complete` handling.
|
|
199
|
+
- `timeoutUnits` in settings.
|
|
200
|
+
- `ignoreUnmatched` paths option in settings.
|
|
201
|
+
- Updated documentation.
|
|
202
|
+
|
|
203
|
+
## [0.3.0] - 2020-01-14
|
|
204
|
+
|
|
205
|
+
- Initial release.
|
package/README.md
CHANGED
|
@@ -61,6 +61,11 @@ you want to wait for. That's it.
|
|
|
61
61
|
> Looking for a working flow? After install, open
|
|
62
62
|
> **Menu → Import → Examples → join-wait** for ready-made flows.
|
|
63
63
|
|
|
64
|
+
> **Tip:** in the **Wait paths** / **Reset paths** lists you can press
|
|
65
|
+
> <kbd>Enter</kbd> in a row to add the next one, and paste newline-separated
|
|
66
|
+
> text into any row to fill the current row and append the rest as new
|
|
67
|
+
> rows — handy for seeding a list from a spreadsheet column or a debug log.
|
|
68
|
+
|
|
64
69
|
## How it works
|
|
65
70
|
|
|
66
71
|
```
|
package/join-wait.html
CHANGED
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
or an object with multiple keys.
|
|
16
16
|
</div>
|
|
17
17
|
|
|
18
|
-
<div class="form-row">
|
|
18
|
+
<div class="form-row" style="margin-bottom:4px">
|
|
19
19
|
<label title="Path names to wait for; repeat an entry to require it n times."><i class="fa fa-code-fork"></i> Wait paths</label>
|
|
20
|
+
</div>
|
|
21
|
+
<div class="form-row node-input-paths-container-row">
|
|
20
22
|
<ol id="node-input-paths-container"></ol>
|
|
21
23
|
</div>
|
|
22
24
|
<div class="form-tips" style="margin-bottom:6px">
|
|
@@ -24,12 +26,12 @@
|
|
|
24
26
|
merged success output. Press <kbd>Enter</kbd> in a row to add the
|
|
25
27
|
next one. Repeat an entry to require n-of-the-same.
|
|
26
28
|
</div>
|
|
27
|
-
<div class="form-tips" id="join-wait-output-preview" style="margin-bottom:12px; font-family:monospace
|
|
29
|
+
<div class="form-tips" id="join-wait-output-preview" style="margin-bottom:12px; font-family:var(--red-ui-monospace-font, monospace)"></div>
|
|
28
30
|
|
|
29
31
|
<div class="form-row">
|
|
30
32
|
<label for="node-input-timeout" title="How long all wait paths have to arrive before the queue is expired."><i class="fa fa-clock-o"></i> Timeout</label>
|
|
31
|
-
<input type="text" id="node-input-timeout" style="text-align:end; width:
|
|
32
|
-
<select id="node-input-timeoutUnits" style="width:
|
|
33
|
+
<input type="text" id="node-input-timeout" style="text-align:end; width:90px" placeholder="15">
|
|
34
|
+
<select id="node-input-timeoutUnits" style="width:160px; margin-left:5px">
|
|
33
35
|
<option value="1">Milliseconds</option>
|
|
34
36
|
<option value="1000">Seconds</option>
|
|
35
37
|
<option value="60000">Minutes</option>
|
|
@@ -37,11 +39,13 @@
|
|
|
37
39
|
<option value="86400000">Days</option>
|
|
38
40
|
</select>
|
|
39
41
|
</div>
|
|
40
|
-
<div class="form-tips" id="join-wait-timeout-hint" style="margin-bottom:12px; color:#b07b00; display:none"
|
|
42
|
+
<div class="form-tips" id="join-wait-timeout-hint" style="margin-bottom:12px; color:#b07b00; display:none">
|
|
43
|
+
<i class="fa fa-exclamation-triangle"></i> <span></span>
|
|
44
|
+
</div>
|
|
41
45
|
|
|
42
46
|
<div class="form-row">
|
|
43
47
|
<label for="node-input-exactOrder" title="Any order (default) or strict sequence."><i class="fa fa-sort"></i> Match order</label>
|
|
44
|
-
<select id="node-input-exactOrder" style="width:70
|
|
48
|
+
<select id="node-input-exactOrder" style="width:70%">
|
|
45
49
|
<option value="false">Any order</option>
|
|
46
50
|
<option value="true">Exact order</option>
|
|
47
51
|
</select>
|
|
@@ -64,11 +68,13 @@
|
|
|
64
68
|
</button>
|
|
65
69
|
</div>
|
|
66
70
|
|
|
67
|
-
<details style="margin-top:14px">
|
|
68
|
-
<summary style="cursor:pointer; font-weight:bold; padding:6px 0">Advanced</summary>
|
|
71
|
+
<details class="join-wait-advanced" style="margin-top:14px">
|
|
72
|
+
<summary style="cursor:pointer; font-weight:bold; padding:6px 0; color:var(--red-ui-header-text-color, inherit)">Advanced</summary>
|
|
69
73
|
|
|
70
|
-
<div class="form-row" style="margin-top:10px">
|
|
74
|
+
<div class="form-row" style="margin-top:10px; margin-bottom:4px">
|
|
71
75
|
<label title="Paths that immediately drain the queue to the expired output."><i class="fa fa-times-circle-o"></i> Reset paths</label>
|
|
76
|
+
</div>
|
|
77
|
+
<div class="form-row node-input-pathsToExpire-container-row">
|
|
72
78
|
<ol id="node-input-pathsToExpire-container"></ol>
|
|
73
79
|
</div>
|
|
74
80
|
<div class="form-tips" style="margin-bottom:12px">
|
|
@@ -78,7 +84,7 @@
|
|
|
78
84
|
|
|
79
85
|
<div class="form-row">
|
|
80
86
|
<label for="node-input-firstMsg" title="Use the first or last message in the matched window as the base of the merged output."><i class="fa fa-comments-o"></i> Output base</label>
|
|
81
|
-
<select id="node-input-firstMsg" style="width:70
|
|
87
|
+
<select id="node-input-firstMsg" style="width:70%">
|
|
82
88
|
<option value="true">First received message</option>
|
|
83
89
|
<option value="false">Last received message</option>
|
|
84
90
|
</select>
|
|
@@ -86,7 +92,7 @@
|
|
|
86
92
|
|
|
87
93
|
<div class="form-row">
|
|
88
94
|
<label for="node-input-mapPayload" title="Keep the original Path-field values, or overwrite each with that message's payload."><i class="fa fa-arrow-right"></i> Merge values</label>
|
|
89
|
-
<select id="node-input-mapPayload" style="width:70
|
|
95
|
+
<select id="node-input-mapPayload" style="width:70%">
|
|
90
96
|
<option value="false">Original Path field values</option>
|
|
91
97
|
<option value="true">Each msg.payload</option>
|
|
92
98
|
</select>
|
|
@@ -116,13 +122,13 @@
|
|
|
116
122
|
<label for="node-input-persistOnRestart" style="width:auto">Preserve queue across deploys / restarts</label>
|
|
117
123
|
</div>
|
|
118
124
|
|
|
119
|
-
<div class="form-row">
|
|
125
|
+
<div class="form-row" id="join-wait-persist-store-row">
|
|
120
126
|
<label for="node-input-persistStore" title="Override the context store used to persist the queue."><i class="fa fa-database"></i> Persist store</label>
|
|
121
|
-
<select id="node-input-persistStore" style="width:70
|
|
127
|
+
<select id="node-input-persistStore" style="width:70%">
|
|
122
128
|
<option value="">(default context store)</option>
|
|
123
129
|
</select>
|
|
124
130
|
</div>
|
|
125
|
-
<div class="form-tips" style="margin-bottom:12px">
|
|
131
|
+
<div class="form-tips" id="join-wait-persist-store-tip" style="margin-bottom:12px">
|
|
126
132
|
Override the context store this node uses. Leave on default — set
|
|
127
133
|
the default to a persistent store (e.g. <code>localfilesystem</code>)
|
|
128
134
|
in <code>settings.js</code> and every join-wait node automatically
|
|
@@ -133,37 +139,65 @@
|
|
|
133
139
|
|
|
134
140
|
<script type="text/javascript">
|
|
135
141
|
(function () {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
142
|
+
// Every DOM hook the editor uses, in one place.
|
|
143
|
+
const SEL = {
|
|
144
|
+
pathField: '#node-input-pathTopic',
|
|
145
|
+
pathFieldType: '#node-input-pathTopicType',
|
|
146
|
+
correlator: '#node-input-correlationTopic',
|
|
147
|
+
correlatorType: '#node-input-correlationTopicType',
|
|
148
|
+
timeout: '#node-input-timeout',
|
|
149
|
+
timeoutUnits: '#node-input-timeoutUnits',
|
|
150
|
+
useRegex: '#node-input-useRegex',
|
|
151
|
+
persistOnRestart: '#node-input-persistOnRestart',
|
|
152
|
+
persistStore: '#node-input-persistStore',
|
|
153
|
+
outputPreview: '#join-wait-output-preview',
|
|
154
|
+
timeoutHint: '#join-wait-timeout-hint',
|
|
155
|
+
examplesButton: '#join-wait-open-examples',
|
|
156
|
+
persistStoreRow: '#join-wait-persist-store-row',
|
|
157
|
+
persistStoreTip: '#join-wait-persist-store-tip',
|
|
158
|
+
dialogForm: '#dialog-form',
|
|
159
|
+
advancedDetails: '#dialog-form details',
|
|
160
|
+
};
|
|
161
|
+
// editableList wrapper IDs (no leading '#'); consumers either
|
|
162
|
+
// pass them to readEditableList or interpolate `#` themselves.
|
|
163
|
+
const LIST_ID = {
|
|
164
|
+
paths: 'node-input-paths-container',
|
|
165
|
+
expire: 'node-input-pathsToExpire-container',
|
|
166
|
+
};
|
|
167
|
+
const listInputs = (id) => $('#' + id + ' input[type=text]');
|
|
168
|
+
// Path-field name + typedInput type, read together to avoid drift.
|
|
169
|
+
const readPathField = () => ({
|
|
170
|
+
name: $(SEL.pathField).val() || 'topic',
|
|
171
|
+
type: $(SEL.pathFieldType).val() || 'msg',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const arrayValidator = (allowEmpty, requireUnique) => (v) => {
|
|
175
|
+
// Accept legacy JSON-string format too, for back-compat with old flows.
|
|
176
|
+
let arr = v;
|
|
177
|
+
if (typeof v === 'string') {
|
|
178
|
+
if (v === '') return allowEmpty;
|
|
179
|
+
try {
|
|
180
|
+
arr = JSON.parse(v);
|
|
181
|
+
} catch (err) {
|
|
155
182
|
return false;
|
|
156
|
-
if (requireUnique) {
|
|
157
|
-
var seen = Object.create(null);
|
|
158
|
-
for (var i = 0; i < arr.length; i++) {
|
|
159
|
-
if (seen[arr[i]]) return false;
|
|
160
|
-
seen[arr[i]] = true;
|
|
161
|
-
}
|
|
162
183
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
184
|
+
}
|
|
185
|
+
if (!Array.isArray(arr)) return false;
|
|
186
|
+
if (arr.length === 0) return allowEmpty;
|
|
187
|
+
if (!arr.every((s) => typeof s === 'string' && s.length > 0)) return false;
|
|
188
|
+
if (requireUnique) {
|
|
189
|
+
const seen = Object.create(null);
|
|
190
|
+
for (let i = 0; i < arr.length; i++) {
|
|
191
|
+
if (seen[arr[i]]) return false;
|
|
192
|
+
seen[arr[i]] = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
};
|
|
166
197
|
|
|
198
|
+
// Note: Node-RED calls validate functions with `this` bound to the
|
|
199
|
+
// node config, so this stays a function expression (arrow would
|
|
200
|
+
// not bind `this`).
|
|
167
201
|
function correlationValidator(v) {
|
|
168
202
|
// Empty / undefined / non-jsonata: nothing to validate here.
|
|
169
203
|
if (this.correlationTopicType !== 'jsonata' || !v) return true;
|
|
@@ -177,162 +211,227 @@
|
|
|
177
211
|
}
|
|
178
212
|
}
|
|
179
213
|
|
|
180
|
-
|
|
214
|
+
const isValidRegex = (s) => {
|
|
181
215
|
try {
|
|
182
216
|
new RegExp(s);
|
|
183
217
|
return true;
|
|
184
218
|
} catch (err) {
|
|
185
219
|
return false;
|
|
186
220
|
}
|
|
187
|
-
}
|
|
221
|
+
};
|
|
188
222
|
|
|
189
|
-
|
|
223
|
+
const setRowValid = (input, ok, message) => {
|
|
190
224
|
input.css('border-color', ok ? '' : '#d9534f');
|
|
191
225
|
input.attr('title', ok ? '' : message || '');
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Single-row validation: empty / invalid regex. List-level
|
|
229
|
+
// checks (uniqueness, cross-list overlap) live in validateAllRows
|
|
230
|
+
// because they need both lists' current values in scope.
|
|
231
|
+
const validateRow = (input, ownValues, otherValues, opts) => {
|
|
232
|
+
const requireUnique = opts && opts.requireUnique;
|
|
233
|
+
const otherListLabel = (opts && opts.otherListLabel) || '';
|
|
234
|
+
const value = String(input.val() || '');
|
|
235
|
+
if (value === '') {
|
|
198
236
|
setRowValid(input, false, 'path name cannot be empty');
|
|
199
237
|
return false;
|
|
200
238
|
}
|
|
201
|
-
if ($useRegex.is(':checked') && !isValidRegex(
|
|
239
|
+
if ($(SEL.useRegex).is(':checked') && !isValidRegex(value)) {
|
|
202
240
|
setRowValid(input, false, 'invalid regular expression');
|
|
203
241
|
return false;
|
|
204
242
|
}
|
|
243
|
+
if (requireUnique && ownValues && ownValues.filter((x) => x === value).length > 1) {
|
|
244
|
+
setRowValid(input, false, 'duplicate entry — must be unique');
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
if (otherValues && otherValues.indexOf(value) !== -1) {
|
|
248
|
+
setRowValid(
|
|
249
|
+
input,
|
|
250
|
+
false,
|
|
251
|
+
'"' + value + '" is also in ' + otherListLabel + " — paths shouldn't overlap",
|
|
252
|
+
);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
205
255
|
setRowValid(input, true);
|
|
206
256
|
return true;
|
|
207
|
-
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Re-validate every row across both lists so duplicate and
|
|
260
|
+
// cross-list overlap warnings stay in sync.
|
|
261
|
+
const validateAllRows = () => {
|
|
262
|
+
const $paths = listInputs(LIST_ID.paths);
|
|
263
|
+
const $expire = listInputs(LIST_ID.expire);
|
|
264
|
+
// jQuery .map / .each callbacks rely on `this`, so they stay
|
|
265
|
+
// as function expressions.
|
|
266
|
+
const pathValues = $paths
|
|
267
|
+
.map(function () {
|
|
268
|
+
return String($(this).val() || '');
|
|
269
|
+
})
|
|
270
|
+
.get();
|
|
271
|
+
const expireValues = $expire
|
|
272
|
+
.map(function () {
|
|
273
|
+
return String($(this).val() || '');
|
|
274
|
+
})
|
|
275
|
+
.get();
|
|
276
|
+
$paths.each(function () {
|
|
277
|
+
validateRow($(this), pathValues, expireValues, {
|
|
278
|
+
requireUnique: false,
|
|
279
|
+
otherListLabel: 'Reset paths',
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
$expire.each(function () {
|
|
283
|
+
validateRow($(this), expireValues, pathValues, {
|
|
284
|
+
requireUnique: true,
|
|
285
|
+
otherListLabel: 'Wait paths',
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
};
|
|
208
289
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
290
|
+
const initEditableList = (containerId, items, opts) => {
|
|
291
|
+
const $container = $('#' + containerId);
|
|
292
|
+
const placeholder = (opts && opts.placeholder) || 'e.g. sensor_1';
|
|
293
|
+
// Numeric height → list scrolls internally past the limit.
|
|
294
|
+
// Recomputed in oneditresize to fill the tray.
|
|
213
295
|
$container.editableList({
|
|
214
|
-
addItem:
|
|
215
|
-
|
|
296
|
+
addItem: (row, index, data) => {
|
|
297
|
+
const value = (data && data.value) || '';
|
|
216
298
|
row.css({ overflow: 'hidden', whiteSpace: 'nowrap' });
|
|
217
|
-
|
|
299
|
+
const input = $('<input/>', {
|
|
218
300
|
type: 'text',
|
|
219
301
|
placeholder: placeholder,
|
|
220
302
|
style: 'width:100%',
|
|
221
303
|
}).val(value);
|
|
222
|
-
input.on('input change blur',
|
|
223
|
-
|
|
304
|
+
input.on('input change blur', () => {
|
|
305
|
+
validateAllRows();
|
|
224
306
|
if (opts && opts.onChange) opts.onChange();
|
|
225
307
|
});
|
|
226
308
|
// Pressing Enter in a row adds the next one.
|
|
227
|
-
input.on('keydown',
|
|
309
|
+
input.on('keydown', (e) => {
|
|
228
310
|
if (e.key === 'Enter' || e.keyCode === 13) {
|
|
229
311
|
e.preventDefault();
|
|
230
312
|
$container.editableList('addItem', { value: '' });
|
|
231
313
|
// Focus the newly-added row.
|
|
232
|
-
setTimeout(
|
|
314
|
+
setTimeout(() => {
|
|
233
315
|
$container.editableList('items').last().find('input').trigger('focus');
|
|
234
316
|
}, 0);
|
|
235
317
|
}
|
|
236
318
|
});
|
|
319
|
+
// Bulk-paste newline-separated text into multiple
|
|
320
|
+
// rows. Single-line pastes fall through to default.
|
|
321
|
+
input.on('paste', (e) => {
|
|
322
|
+
const clip = (e.originalEvent || e).clipboardData;
|
|
323
|
+
if (!clip) return;
|
|
324
|
+
const text = clip.getData('text');
|
|
325
|
+
if (!text || !/[\r\n]/.test(text)) return;
|
|
326
|
+
const lines = text
|
|
327
|
+
.split(/\r?\n/)
|
|
328
|
+
.map((s) => s.trim())
|
|
329
|
+
.filter((s) => s.length > 0);
|
|
330
|
+
if (lines.length === 0) return;
|
|
331
|
+
e.preventDefault();
|
|
332
|
+
input.val(lines[0]);
|
|
333
|
+
for (let i = 1; i < lines.length; i++) {
|
|
334
|
+
$container.editableList('addItem', { value: lines[i] });
|
|
335
|
+
}
|
|
336
|
+
validateAllRows();
|
|
337
|
+
if (opts && opts.onChange) opts.onChange();
|
|
338
|
+
});
|
|
237
339
|
row.append(input);
|
|
238
|
-
|
|
340
|
+
validateAllRows();
|
|
239
341
|
if (opts && opts.onChange) opts.onChange();
|
|
240
342
|
},
|
|
241
|
-
removeItem:
|
|
343
|
+
removeItem: () => {
|
|
344
|
+
validateAllRows();
|
|
242
345
|
if (opts && opts.onChange) opts.onChange();
|
|
243
346
|
},
|
|
244
347
|
removable: true,
|
|
245
348
|
sortable: true,
|
|
246
|
-
height:
|
|
349
|
+
height: (opts && opts.height) || 200,
|
|
247
350
|
});
|
|
248
|
-
(items || []).forEach(
|
|
351
|
+
(items || []).forEach((v) => {
|
|
249
352
|
$container.editableList('addItem', { value: v });
|
|
250
353
|
});
|
|
251
|
-
}
|
|
354
|
+
};
|
|
252
355
|
|
|
253
356
|
// Re-validate every editableList row when the regex toggle flips.
|
|
254
|
-
|
|
255
|
-
$(
|
|
256
|
-
|
|
257
|
-
'#node-input-paths-container input[type=text], ' +
|
|
258
|
-
'#node-input-pathsToExpire-container input[type=text]',
|
|
259
|
-
).each(function () {
|
|
260
|
-
validateRow($(this));
|
|
261
|
-
});
|
|
262
|
-
});
|
|
263
|
-
}
|
|
357
|
+
const rebindRegexValidation = () => {
|
|
358
|
+
$(SEL.useRegex).on('change', validateAllRows);
|
|
359
|
+
};
|
|
264
360
|
|
|
265
|
-
|
|
266
|
-
|
|
361
|
+
const readEditableList = (containerId) => {
|
|
362
|
+
const values = [];
|
|
267
363
|
$('#' + containerId)
|
|
268
364
|
.editableList('items')
|
|
269
365
|
.each(function () {
|
|
270
|
-
|
|
366
|
+
// jQuery `each` binds `this` to the row element, so
|
|
367
|
+
// this callback stays a function expression.
|
|
368
|
+
const v = $(this).find('input').val();
|
|
271
369
|
if (v != null && v !== '') values.push(v);
|
|
272
370
|
});
|
|
273
371
|
return values;
|
|
274
|
-
}
|
|
372
|
+
};
|
|
275
373
|
|
|
276
374
|
// Coerce legacy string-array values into a plain array for editing.
|
|
277
|
-
|
|
375
|
+
const toArray = (v) => {
|
|
278
376
|
if (Array.isArray(v)) return v;
|
|
279
377
|
if (typeof v === 'string' && v !== '') {
|
|
280
378
|
try {
|
|
281
|
-
|
|
379
|
+
const parsed = JSON.parse(v);
|
|
282
380
|
return Array.isArray(parsed) ? parsed : [];
|
|
283
381
|
} catch (err) {
|
|
284
382
|
return [];
|
|
285
383
|
}
|
|
286
384
|
}
|
|
287
385
|
return [];
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
// Preview of the merged success output. Repeated entries
|
|
389
|
+
// surface as `(×n)` so the n-of-the-same semantic is visible.
|
|
390
|
+
const updateOutputPreview = () => {
|
|
391
|
+
const paths = readEditableList(LIST_ID.paths);
|
|
392
|
+
const counts = Object.create(null);
|
|
393
|
+
const order = [];
|
|
394
|
+
paths.forEach((p) => {
|
|
395
|
+
if (counts[p] === undefined) order.push(p);
|
|
396
|
+
counts[p] = (counts[p] || 0) + 1;
|
|
297
397
|
});
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (
|
|
398
|
+
const field = readPathField();
|
|
399
|
+
const $tip = $(SEL.outputPreview);
|
|
400
|
+
if (order.length === 0) {
|
|
301
401
|
$tip.text('');
|
|
302
402
|
return;
|
|
303
403
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
$tip.text('→ msg.' + pathField + ' = { ' + entries.join(', ') + ' }');
|
|
309
|
-
}
|
|
404
|
+
const entries = order.slice(0, 4).map((p) => (counts[p] > 1 ? p + ' (×' + counts[p] + '): …' : p + ': …'));
|
|
405
|
+
if (order.length > 4) entries.push('…');
|
|
406
|
+
$tip.text('→ ' + field.type + '.' + field.name + ' = { ' + entries.join(', ') + ' }');
|
|
407
|
+
};
|
|
310
408
|
|
|
311
409
|
// Warn when the resolved timeout is impractically short (the README
|
|
312
410
|
// recommends padding ~5–10 ms for evaluation overhead).
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
411
|
+
const updateTimeoutHint = () => {
|
|
412
|
+
const t = Number($(SEL.timeout).val()) || 0;
|
|
413
|
+
const u = Number($(SEL.timeoutUnits).val()) || 1;
|
|
414
|
+
const ms = t * u;
|
|
415
|
+
const $hint = $(SEL.timeoutHint);
|
|
318
416
|
if (ms > 0 && ms < 50) {
|
|
319
|
-
$hint.text('Very short — pad ~10 ms to leave room for evaluation overhead.')
|
|
417
|
+
$hint.find('span').text('Very short — pad ~10 ms to leave room for evaluation overhead.');
|
|
418
|
+
$hint.show();
|
|
320
419
|
} else {
|
|
321
420
|
$hint.hide();
|
|
322
421
|
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Populate the Persist store <select> from
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
stores.forEach(
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
// Populate the Persist store <select> from /join-wait/stores.
|
|
425
|
+
// apiRootUrl prefixes the call so it resolves under custom
|
|
426
|
+
// httpAdminRoot / reverse proxies.
|
|
427
|
+
const populatePersistStores = (currentValue) => {
|
|
428
|
+
const $sel = $(SEL.persistStore);
|
|
429
|
+
const base = (RED.settings && RED.settings.apiRootUrl) || '';
|
|
430
|
+
$.getJSON(base + 'join-wait/stores')
|
|
431
|
+
.done((stores) => {
|
|
432
|
+
(Array.isArray(stores) ? stores : []).forEach((s) => {
|
|
334
433
|
if (s.name === 'default') return; // already represented as the blank option
|
|
335
|
-
|
|
434
|
+
const label = s.module ? s.name + ' (' + s.module + ')' : s.name;
|
|
336
435
|
$('<option/>').val(s.name).text(label).appendTo($sel);
|
|
337
436
|
});
|
|
338
437
|
// If the saved value isn't in the list, append it so we
|
|
@@ -345,28 +444,79 @@
|
|
|
345
444
|
}
|
|
346
445
|
$sel.val(currentValue || '');
|
|
347
446
|
})
|
|
348
|
-
.fail(
|
|
349
|
-
// Admin route not reachable
|
|
350
|
-
//
|
|
447
|
+
.fail(() => {
|
|
448
|
+
// Admin route not reachable. Preserve the saved
|
|
449
|
+
// value as an explicit option so the dropdown doesn't
|
|
450
|
+
// silently overwrite persistStore with '' on save.
|
|
451
|
+
if (currentValue) {
|
|
452
|
+
$('<option/>')
|
|
453
|
+
.val(currentValue)
|
|
454
|
+
.text(currentValue + ' (stores list unavailable)')
|
|
455
|
+
.appendTo($sel);
|
|
456
|
+
$sel.val(currentValue);
|
|
457
|
+
}
|
|
351
458
|
});
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
// Last `size` handed to oneditresize. Cached so the <details>
|
|
462
|
+
// toggle handler can re-run the resize math without waiting for
|
|
463
|
+
// the next tray drag.
|
|
464
|
+
let lastTraySize = null;
|
|
465
|
+
|
|
466
|
+
// Stretch the Wait paths editableList to fill the tray —
|
|
467
|
+
// pattern from core switch/change/httprequest.
|
|
468
|
+
const resizePathsList = () => {
|
|
469
|
+
const $form = $(SEL.dialogForm);
|
|
470
|
+
if (!$form.length || !lastTraySize || !lastTraySize.height) return;
|
|
471
|
+
let height = lastTraySize.height;
|
|
472
|
+
// jQuery `.each` binds `this` to the row element, so this
|
|
473
|
+
// callback stays a function expression.
|
|
474
|
+
$form.children(':not(.node-input-paths-container-row)').each(function () {
|
|
475
|
+
const $r = $(this);
|
|
476
|
+
if ($r.is(':visible')) height -= $r.outerHeight(true) || 0;
|
|
477
|
+
});
|
|
478
|
+
height -= 12; // breathing room for margins/padding the loop misses
|
|
479
|
+
if (height < 140) height = 140;
|
|
480
|
+
$('#' + LIST_ID.paths).editableList('height', height);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// clipboard.js disables import actions while an edit tray is
|
|
484
|
+
// open, and RED.tray.close(cb) fires cb before the editor:close
|
|
485
|
+
// event — so we listen for editor:close ourselves and defer one
|
|
486
|
+
// tick past the synchronous listener chain that clears the flag.
|
|
487
|
+
// The pending guard suppresses a fast double-click registering
|
|
488
|
+
// two listeners.
|
|
489
|
+
let pendingExamplesOpen = false;
|
|
490
|
+
const openExamplesDialog = () => {
|
|
491
|
+
if (pendingExamplesOpen) return;
|
|
492
|
+
pendingExamplesOpen = true;
|
|
493
|
+
const invoke = () => {
|
|
494
|
+
setTimeout(() => {
|
|
495
|
+
pendingExamplesOpen = false;
|
|
496
|
+
if (!RED.actions || typeof RED.actions.invoke !== 'function') return;
|
|
497
|
+
// core:show-examples-import-dialog was added in Node-RED 3.1.
|
|
498
|
+
const actions = RED.actions.list && RED.actions.list();
|
|
499
|
+
const hasExamples =
|
|
500
|
+
Array.isArray(actions) &&
|
|
501
|
+
actions.some((a) => (a && a.id) === 'core:show-examples-import-dialog');
|
|
502
|
+
RED.actions.invoke(hasExamples ? 'core:show-examples-import-dialog' : 'core:show-import-dialog');
|
|
503
|
+
}, 0);
|
|
363
504
|
};
|
|
505
|
+
if (RED.events && typeof RED.events.once === 'function') {
|
|
506
|
+
RED.events.once('editor:close', invoke);
|
|
507
|
+
} else if (RED.events && typeof RED.events.on === 'function') {
|
|
508
|
+
const handler = () => {
|
|
509
|
+
if (RED.events.off) RED.events.off('editor:close', handler);
|
|
510
|
+
invoke();
|
|
511
|
+
};
|
|
512
|
+
RED.events.on('editor:close', handler);
|
|
513
|
+
}
|
|
364
514
|
if (RED.tray && typeof RED.tray.close === 'function') {
|
|
365
|
-
RED.tray.close(
|
|
515
|
+
RED.tray.close();
|
|
366
516
|
} else {
|
|
367
517
|
invoke();
|
|
368
518
|
}
|
|
369
|
-
}
|
|
519
|
+
};
|
|
370
520
|
|
|
371
521
|
RED.nodes.registerType('join-wait', {
|
|
372
522
|
category: 'function',
|
|
@@ -393,10 +543,9 @@
|
|
|
393
543
|
firstMsg: { value: 'true', required: true },
|
|
394
544
|
mapPayload: { value: 'false', required: true },
|
|
395
545
|
disableComplete: { value: false },
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
// to point Persist store at a configured persistent store.
|
|
546
|
+
// On by default → survives redeploys via the in-memory
|
|
547
|
+
// context store. Restart-persistence still needs Persist
|
|
548
|
+
// store pointing at a persistent context.
|
|
400
549
|
persistOnRestart: { value: true },
|
|
401
550
|
persistStore: { value: '' },
|
|
402
551
|
},
|
|
@@ -411,14 +560,14 @@
|
|
|
411
560
|
return this.name ? 'node_label_italic' : '';
|
|
412
561
|
},
|
|
413
562
|
oneditprepare: function () {
|
|
414
|
-
$(
|
|
563
|
+
$(SEL.pathField).typedInput({
|
|
415
564
|
default: this.pathTopicType || 'msg',
|
|
416
|
-
typeField: $(
|
|
565
|
+
typeField: $(SEL.pathFieldType),
|
|
417
566
|
types: ['msg', 'flow', 'global'],
|
|
418
567
|
});
|
|
419
|
-
$(
|
|
568
|
+
$(SEL.correlator).typedInput({
|
|
420
569
|
default: this.correlationTopicType || 'undefined',
|
|
421
|
-
typeField: $(
|
|
570
|
+
typeField: $(SEL.correlatorType),
|
|
422
571
|
types: [
|
|
423
572
|
{ value: 'undefined', label: RED._('common.type.undefined'), hasValue: false },
|
|
424
573
|
'msg',
|
|
@@ -427,34 +576,50 @@
|
|
|
427
576
|
'jsonata',
|
|
428
577
|
],
|
|
429
578
|
});
|
|
430
|
-
|
|
579
|
+
// Decimals OK (runtime multiplies by the unit factor).
|
|
580
|
+
$(SEL.timeout).spinner({ min: 0.001, step: 1, numberFormat: 'n' });
|
|
431
581
|
|
|
432
|
-
initEditableList(
|
|
582
|
+
initEditableList(LIST_ID.paths, toArray(this.paths), {
|
|
433
583
|
placeholder: 'e.g. sensor_1',
|
|
434
584
|
onChange: updateOutputPreview,
|
|
585
|
+
height: 200,
|
|
435
586
|
});
|
|
436
|
-
initEditableList(
|
|
587
|
+
initEditableList(LIST_ID.expire, toArray(this.pathsToExpire), {
|
|
437
588
|
placeholder: 'e.g. abort',
|
|
589
|
+
height: 140,
|
|
438
590
|
});
|
|
439
591
|
rebindRegexValidation();
|
|
440
592
|
|
|
441
|
-
$(
|
|
593
|
+
$(SEL.pathField).on('change input', updateOutputPreview);
|
|
442
594
|
updateOutputPreview();
|
|
443
595
|
|
|
444
|
-
$('
|
|
596
|
+
$(SEL.timeout + ', ' + SEL.timeoutUnits).on('change input', updateTimeoutHint);
|
|
445
597
|
updateTimeoutHint();
|
|
446
598
|
|
|
447
599
|
populatePersistStores(this.persistStore);
|
|
448
600
|
|
|
449
|
-
|
|
601
|
+
// Persist store row is meaningless without Preserve queue.
|
|
602
|
+
const $persistRow = $(SEL.persistStoreRow + ', ' + SEL.persistStoreTip);
|
|
603
|
+
const syncPersistStoreVisibility = () => {
|
|
604
|
+
$persistRow.toggle($(SEL.persistOnRestart).is(':checked'));
|
|
605
|
+
resizePathsList();
|
|
606
|
+
};
|
|
607
|
+
$(SEL.persistOnRestart).on('change', syncPersistStoreVisibility);
|
|
608
|
+
syncPersistStoreVisibility();
|
|
609
|
+
|
|
610
|
+
$(SEL.examplesButton).on('click', openExamplesDialog);
|
|
611
|
+
|
|
612
|
+
// Reflow the list when Advanced toggles (oneditresize
|
|
613
|
+
// only fires on tray drag).
|
|
614
|
+
$(SEL.advancedDetails).on('toggle', resizePathsList);
|
|
450
615
|
},
|
|
451
616
|
oneditsave: function () {
|
|
452
|
-
this.paths = readEditableList(
|
|
453
|
-
this.pathsToExpire = readEditableList(
|
|
617
|
+
this.paths = readEditableList(LIST_ID.paths);
|
|
618
|
+
this.pathsToExpire = readEditableList(LIST_ID.expire);
|
|
454
619
|
},
|
|
455
|
-
oneditresize: function () {
|
|
456
|
-
|
|
457
|
-
|
|
620
|
+
oneditresize: function (size) {
|
|
621
|
+
lastTraySize = size;
|
|
622
|
+
resizePathsList();
|
|
458
623
|
},
|
|
459
624
|
});
|
|
460
625
|
})();
|
|
@@ -550,8 +715,12 @@
|
|
|
550
715
|
is kept in the merged output.</li>
|
|
551
716
|
<li>Pad <b>Timeout</b> with a small overhead (~5–10 ms) for
|
|
552
717
|
evaluation time when working with very short windows.</li>
|
|
553
|
-
<li>When <b>Preserve queue</b> is on, the queue is
|
|
554
|
-
|
|
555
|
-
|
|
718
|
+
<li>When <b>Preserve queue</b> is on, the queue is written to the
|
|
719
|
+
node's context on close (and reloaded on the next start). With
|
|
720
|
+
the default in-memory store this only survives a redeploy; for
|
|
721
|
+
full restart persistence, point <b>Persist store</b> at a
|
|
722
|
+
store backed by <code>localfilesystem</code> (or any persistent
|
|
723
|
+
module) configured under <code>contextStorage</code> in
|
|
724
|
+
<code>settings.js</code>.</li>
|
|
556
725
|
</ul>
|
|
557
726
|
</script>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-red-contrib-join-wait",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Node-RED node that joins related messages across multiple paths within a time window — with exact-order matching, regex paths, correlation grouping, reset paths, and queue persistence. Coordinate parallel flows, synchronize events, and debounce sensors.",
|
|
5
5
|
"author": "Daniel Caspi <dan@element26.net>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"main": "join-wait.js",
|
|
26
26
|
"engines": {
|
|
27
|
-
"node": ">=
|
|
27
|
+
"node": ">=18.5"
|
|
28
28
|
},
|
|
29
29
|
"files": [
|
|
30
30
|
"join-wait.js",
|
|
@@ -63,8 +63,7 @@
|
|
|
63
63
|
"version": ">=3.0.0",
|
|
64
64
|
"nodes": {
|
|
65
65
|
"join-wait": "join-wait.js"
|
|
66
|
-
}
|
|
67
|
-
"examples": "examples"
|
|
66
|
+
}
|
|
68
67
|
},
|
|
69
68
|
"dependencies": {
|
|
70
69
|
"jsonata": "^2.0.5"
|