@cyprnet/node-red-contrib-uibuilder-formgen 0.5.14 → 0.5.17
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 +13 -0
- package/docs/user-guide.html +80 -6
- package/examples/formgen-builder/src/index.html +64 -0
- package/nodes/uibuilder-formgen-v3.html +55 -0
- package/nodes/uibuilder-formgen-v3.js +1 -1
- package/nodes/uibuilder-formgen.html +32 -0
- package/nodes/uibuilder-formgen.js +1 -1
- package/package.json +1 -1
- package/templates/index.html.mustache +33 -3
- package/templates/index.js.mustache +135 -33
- package/templates/index.v3.html.mustache +18 -2
- package/templates/index.v3.js.mustache +97 -12
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,19 @@ All notable changes to this package will be documented in this file.
|
|
|
14
14
|
|
|
15
15
|
- Dynamic Lookup/Auto-fill (Node-RED source): portal now sends <code>matchValue</code> with <code>lookup:get</code> and caches per selected value, enabling Node-RED to return filtered records based on the user’s selection (e.g., OS).
|
|
16
16
|
|
|
17
|
+
## 0.5.15
|
|
18
|
+
|
|
19
|
+
- Lookup/Auto-fill (dynamic): improved per-selection requests and pending/timeout handling to make <code>lookup:get</code> reliable.
|
|
20
|
+
- Documentation: expanded <code>schema.lookups</code> formatting guidance and added dynamic lookup examples in Node-RED help + Schema Builder help.
|
|
21
|
+
|
|
22
|
+
## 0.5.17
|
|
23
|
+
|
|
24
|
+
- Results view: portals now also display **generic results** sent via uibuilder (e.g. <code>msg.payload.result</code> or an array payload), not only <code>submit:ok</code>/<code>submit:error</code>.
|
|
25
|
+
|
|
26
|
+
## 0.5.16
|
|
27
|
+
|
|
28
|
+
- Results view: added pagination controls (default 15 rows/page; selector 10/15/25/50/100/1000) that appear when results exceed 10 records, for both Vue 2 and Vue 3 portals.
|
|
29
|
+
|
|
17
30
|
## 0.5.11
|
|
18
31
|
|
|
19
32
|
- Lookup / Auto-fill: lookup items can now store lists as **arrays** (e.g. <code>["a","b"]</code>) and the portal will format arrays for textarea/keyvalue targets. Builder Lookups editor now includes a **Simple list** mode (one item per line → JSON array).
|
package/docs/user-guide.html
CHANGED
|
@@ -502,6 +502,52 @@
|
|
|
502
502
|
}]
|
|
503
503
|
}</pre>
|
|
504
504
|
|
|
505
|
+
<h4>How to format <code>schema.lookups</code> (custom lists)</h4>
|
|
506
|
+
<p>
|
|
507
|
+
Lookup lists live at <code>schema.lookups</code> and can be any of the following shapes. The portal treats the list as an array of “items”.
|
|
508
|
+
Your <code>matchKey</code> and <code>valueKey</code> settings determine which properties are read from each item.
|
|
509
|
+
</p>
|
|
510
|
+
|
|
511
|
+
<h5>Recommended: array of objects (most flexible)</h5>
|
|
512
|
+
<p>Use this for OS profiles, templates, multi-field mappings, etc.</p>
|
|
513
|
+
<pre>{
|
|
514
|
+
"lookups": {
|
|
515
|
+
"osProfiles": [
|
|
516
|
+
{
|
|
517
|
+
"value": "win11",
|
|
518
|
+
"label": "Windows 11",
|
|
519
|
+
"devices": ["Dell 123", "Dell 321"],
|
|
520
|
+
"software": ["M365 Apps", "Teams"]
|
|
521
|
+
}
|
|
522
|
+
]
|
|
523
|
+
}
|
|
524
|
+
}</pre>
|
|
525
|
+
|
|
526
|
+
<h5>Simple list: array of strings</h5>
|
|
527
|
+
<p>Use this when you only need one string value per item.</p>
|
|
528
|
+
<pre>{
|
|
529
|
+
"lookups": {
|
|
530
|
+
"departments": ["IT", "HR", "Finance"]
|
|
531
|
+
}
|
|
532
|
+
}</pre>
|
|
533
|
+
|
|
534
|
+
<h5>Object/dictionary (also supported)</h5>
|
|
535
|
+
<p>If you prefer key/value maps, you can store an object. It is normalized into an array internally.</p>
|
|
536
|
+
<pre>{
|
|
537
|
+
"lookups": {
|
|
538
|
+
"statusLabels": {
|
|
539
|
+
"new": "New",
|
|
540
|
+
"in_progress": "In Progress",
|
|
541
|
+
"closed": "Closed"
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}</pre>
|
|
545
|
+
|
|
546
|
+
<div class="note">
|
|
547
|
+
<strong>Tip:</strong> Prefer arrays over multiline strings. If you want textarea-friendly lists, store <code>devices</code>/<code>software</code> as arrays of strings.
|
|
548
|
+
The portal will join string arrays into lines when mapping into a <code>textarea</code>.
|
|
549
|
+
</div>
|
|
550
|
+
|
|
505
551
|
<h4>Dynamic list fetched from Node-RED (recommended for centralized “source of truth”)</h4>
|
|
506
552
|
<p>
|
|
507
553
|
Use this when the lookup list should come from Node-RED (file, database, API, etc.) and you want updates without regenerating the portal.
|
|
@@ -519,14 +565,26 @@
|
|
|
519
565
|
}</pre>
|
|
520
566
|
|
|
521
567
|
<h4>Node-RED message contract (dynamic source)</h4>
|
|
522
|
-
|
|
568
|
+
|
|
569
|
+
<h5>Request message (portal → Node-RED)</h5>
|
|
570
|
+
<p>When a user changes the source field, the portal sends a request to Node-RED via uibuilder (output #1):</p>
|
|
523
571
|
<pre>{
|
|
524
|
-
"payload": { "type": "lookup:get", "lookupId": "deviceTemplates" }
|
|
572
|
+
"payload": { "type": "lookup:get", "lookupId": "deviceTemplates", "matchValue": "win11" }
|
|
525
573
|
}</pre>
|
|
526
|
-
<
|
|
574
|
+
<ul>
|
|
575
|
+
<li><strong>lookupId</strong>: identifies which lookup list/service you want (you define this string)</li>
|
|
576
|
+
<li><strong>matchValue</strong>: the current selected value from the source field (optional, but recommended for “query-by-value” lookups)</li>
|
|
577
|
+
</ul>
|
|
578
|
+
|
|
579
|
+
<h5>Response message (Node-RED → portal)</h5>
|
|
580
|
+
<p>Your flow should return the list back to the portal via the same uibuilder node’s input:</p>
|
|
527
581
|
<pre>{
|
|
528
|
-
"payload": { "type": "lookup:data", "lookupId": "deviceTemplates", "items": [ ... ] }
|
|
582
|
+
"payload": { "type": "lookup:data", "lookupId": "deviceTemplates", "matchValue": "win11", "items": [ ... ] }
|
|
529
583
|
}</pre>
|
|
584
|
+
<div class="note ok">
|
|
585
|
+
<strong>Important:</strong> Return <code>items</code> as an <strong>array</strong>. Each item must contain the properties your mappings reference.
|
|
586
|
+
Include the same <code>matchValue</code> so the portal can cache responses per selection.
|
|
587
|
+
</div>
|
|
530
588
|
<div class="note">
|
|
531
589
|
<strong>Dynamic lookup query values:</strong> When using a Node-RED (dynamic) lookup, the portal will request data as
|
|
532
590
|
<code>{type:"lookup:get", lookupId:"...", matchValue:"..."}</code> where <code>matchValue</code> is the user’s current selection/value.
|
|
@@ -539,8 +597,9 @@
|
|
|
539
597
|
|
|
540
598
|
<h4>Minimal dynamic lookup handler (Function node example)</h4>
|
|
541
599
|
<p>
|
|
542
|
-
This is a simple pattern you can use in your flow:
|
|
543
|
-
|
|
600
|
+
This is a simple pattern you can use in your flow:
|
|
601
|
+
connect <strong>uibuilder output #1</strong> → <strong>Function</strong> → <strong>uibuilder input</strong>.
|
|
602
|
+
It responds to <code>lookup:get</code> requests (optionally filtered by <code>matchValue</code>).
|
|
544
603
|
</p>
|
|
545
604
|
<pre>// Function node
|
|
546
605
|
const p = msg.payload || {};
|
|
@@ -564,6 +623,21 @@ if (p.lookupId === "deviceTemplates") {
|
|
|
564
623
|
msg.payload = { type: "lookup:error", lookupId: p.lookupId, error: "Unknown lookupId" };
|
|
565
624
|
return msg;</pre>
|
|
566
625
|
|
|
626
|
+
<h4>How the portal uses the response</h4>
|
|
627
|
+
<ul>
|
|
628
|
+
<li>The portal matches the user’s selected value against each item’s <code>matchKey</code> (commonly <code>value</code>).</li>
|
|
629
|
+
<li>For each mapping, it copies <code>valueKey</code> from the matched item into the target field.</li>
|
|
630
|
+
<li>If a mapping returns an <strong>array of strings</strong> and the target is a <code>textarea</code>, the portal joins the array into lines automatically.</li>
|
|
631
|
+
</ul>
|
|
632
|
+
|
|
633
|
+
<h4>Common pitfalls</h4>
|
|
634
|
+
<ul>
|
|
635
|
+
<li><strong>Wrong lookupId</strong>: request and response lookupId must match exactly.</li>
|
|
636
|
+
<li><strong>Missing matchValue in response</strong>: include it so caching works reliably.</li>
|
|
637
|
+
<li><strong>matchKey mismatch</strong>: if your select uses values like <code>win11</code>, set <code>matchKey</code> to <code>value</code> (not <code>label</code>).</li>
|
|
638
|
+
<li><strong>valueKey mismatch</strong>: ensure the returned item contains the properties your mappings reference.</li>
|
|
639
|
+
</ul>
|
|
640
|
+
|
|
567
641
|
<h4>Schema Builder: how to configure Lookup / Auto-fill</h4>
|
|
568
642
|
<ol>
|
|
569
643
|
<li>Edit (or add) the field you want to <strong>receive</strong> auto-filled values.</li>
|
|
@@ -1013,6 +1013,9 @@ email{{currentField.keyvalueDelimiter || '='}}john@example.com</code></pre>
|
|
|
1013
1013
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
1014
1014
|
<strong>Editor</strong>
|
|
1015
1015
|
<div>
|
|
1016
|
+
<b-button size="sm" variant="outline-info" class="mr-2" @click="showHelp = true" title="How to format lookup lists">
|
|
1017
|
+
<i class="fa fa-info-circle"></i> List format help
|
|
1018
|
+
</b-button>
|
|
1016
1019
|
<b-button size="sm" variant="outline-info" class="mr-2" @click="convertLookupMultilineToArrays" :disabled="lookupEditor.mode !== 'auto'">
|
|
1017
1020
|
<i class="fa fa-exchange"></i> Convert multiline → arrays
|
|
1018
1021
|
</b-button>
|
|
@@ -1025,6 +1028,13 @@ email{{currentField.keyvalueDelimiter || '='}}john@example.com</code></pre>
|
|
|
1025
1028
|
</div>
|
|
1026
1029
|
</div>
|
|
1027
1030
|
|
|
1031
|
+
<b-alert variant="info" show class="mb-3">
|
|
1032
|
+
<strong>Lookup list format:</strong> Recommended is an <strong>array of objects</strong>. Example (OS profiles):
|
|
1033
|
+
<pre class="bg-light p-2 rounded mb-0">[
|
|
1034
|
+
{ "value": "win11", "label": "Windows 11", "devices": ["Dell 123","Dell 321"], "software": ["M365 Apps","Teams"] }
|
|
1035
|
+
]</pre>
|
|
1036
|
+
</b-alert>
|
|
1037
|
+
|
|
1028
1038
|
<b-form-group label="List name" label-for="lookup-name">
|
|
1029
1039
|
<b-form-input id="lookup-name" v-model="lookupEditor.name" placeholder="e.g., osProfiles" />
|
|
1030
1040
|
<small class="form-text text-muted">
|
|
@@ -1184,6 +1194,60 @@ phone,Phone Number,text,true,personal,+1 555-1234,Include country code,phone</pr
|
|
|
1184
1194
|
<li><strong>Select/Radio:</strong> Must have at least one option defined</li>
|
|
1185
1195
|
<li><strong>Required Fields:</strong> Will be validated before form submission</li>
|
|
1186
1196
|
</ul>
|
|
1197
|
+
|
|
1198
|
+
<h4 class="mt-4">Lookup / Auto-fill (populate fields from templates/profiles)</h4>
|
|
1199
|
+
<p>
|
|
1200
|
+
Lookup / Auto-fill lets you choose a value in one field and automatically populate other fields. This is useful for
|
|
1201
|
+
OS profiles, device templates, standard configs, etc.
|
|
1202
|
+
</p>
|
|
1203
|
+
<ul>
|
|
1204
|
+
<li><strong>Static (schema.lookups):</strong> store lists in <code>schema.lookups</code> and reference them by name.</li>
|
|
1205
|
+
<li><strong>Dynamic (Node-RED):</strong> fetch data from Node-RED via uibuilder messages (recommended for a centralized “source of truth”).</li>
|
|
1206
|
+
</ul>
|
|
1207
|
+
|
|
1208
|
+
<h5 class="mt-3">How to configure a field</h5>
|
|
1209
|
+
<ol>
|
|
1210
|
+
<li>Edit the field that should be auto-filled (the target field).</li>
|
|
1211
|
+
<li>Open the <strong>Lookup / Auto-fill</strong> card and enable it.</li>
|
|
1212
|
+
<li>Set <strong>From field</strong> to the source field id (e.g., <code>os</code>).</li>
|
|
1213
|
+
<li>Choose the source:
|
|
1214
|
+
<ul>
|
|
1215
|
+
<li><strong>Schema</strong>: set a <strong>List name</strong> that exists in <code>schema.lookups</code>.</li>
|
|
1216
|
+
<li><strong>Node-RED</strong>: set a <strong>Lookup ID</strong> (a string your flow will handle).</li>
|
|
1217
|
+
</ul>
|
|
1218
|
+
</li>
|
|
1219
|
+
<li>Set <strong>Match key</strong> (commonly <code>value</code>).</li>
|
|
1220
|
+
<li>Add one or more <strong>Mappings</strong>:
|
|
1221
|
+
<ul>
|
|
1222
|
+
<li><strong>Target field</strong>: which field to fill</li>
|
|
1223
|
+
<li><strong>Value key</strong>: which property to copy from the matched item (e.g., <code>devices</code>)</li>
|
|
1224
|
+
</ul>
|
|
1225
|
+
</li>
|
|
1226
|
+
</ol>
|
|
1227
|
+
|
|
1228
|
+
<h5 class="mt-3">Dynamic lookup message contract (Node-RED)</h5>
|
|
1229
|
+
<p><strong>Portal → Node-RED</strong> (uibuilder output #1):</p>
|
|
1230
|
+
<pre class="bg-light p-2 rounded">{
|
|
1231
|
+
"payload": { "type": "lookup:get", "lookupId": "osProfiles", "matchValue": "win11" }
|
|
1232
|
+
}</pre>
|
|
1233
|
+
<p><strong>Node-RED → Portal</strong> (uibuilder input):</p>
|
|
1234
|
+
<pre class="bg-light p-2 rounded">{
|
|
1235
|
+
"payload": {
|
|
1236
|
+
"type": "lookup:data",
|
|
1237
|
+
"lookupId": "osProfiles",
|
|
1238
|
+
"matchValue": "win11",
|
|
1239
|
+
"items": [
|
|
1240
|
+
{ "value": "win11", "label": "Windows 11", "devices": ["Dell 123"], "software": ["M365 Apps","Teams"] }
|
|
1241
|
+
]
|
|
1242
|
+
}
|
|
1243
|
+
}</pre>
|
|
1244
|
+
|
|
1245
|
+
<h5 class="mt-3">Common pitfalls</h5>
|
|
1246
|
+
<ul>
|
|
1247
|
+
<li><strong>It “reverts” to schema source:</strong> Node-RED (dynamic) requires a non-empty <strong>Lookup ID</strong>.</li>
|
|
1248
|
+
<li><strong>Nothing fills:</strong> check <code>matchKey</code> matches your select values (e.g. <code>value</code> == <code>win11</code>).</li>
|
|
1249
|
+
<li><strong>Mappings don’t work:</strong> ensure the response items contain the properties referenced by <code>valueKey</code>.</li>
|
|
1250
|
+
</ul>
|
|
1187
1251
|
|
|
1188
1252
|
<h4 class="mt-4">Tips</h4>
|
|
1189
1253
|
<ul>
|
|
@@ -244,6 +244,61 @@
|
|
|
244
244
|
Generates a uibuilder instance (`src/index.html`, `src/index.js`, `src/form.schema.json`) from `msg.schema`.
|
|
245
245
|
|
|
246
246
|
This node generates portals using **Vue 3 + Bootstrap 5** (native HTML controls).
|
|
247
|
+
|
|
248
|
+
## Lookup / Auto-fill (static vs dynamic)
|
|
249
|
+
|
|
250
|
+
PortalSmith supports “smart fields” that auto-populate other fields based on a selected value.
|
|
251
|
+
|
|
252
|
+
- **Static (schema.lookups)**: lookup lists live in the schema (`schema.lookups.<name>`). No Node-RED flow changes required.
|
|
253
|
+
- **Dynamic (Node-RED via uibuilder)**: the portal requests data from your flow using uibuilder messages.
|
|
254
|
+
|
|
255
|
+
## Dynamic lookup message contract (Node-RED source)
|
|
256
|
+
|
|
257
|
+
When a field changes (e.g., OS selection), the portal sends to Node-RED (uibuilder output #1):
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
{ "payload": { "type": "lookup:get", "lookupId": "osProfiles", "matchValue": "win11" } }
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
- `lookupId`: identifies which lookup list/service you want (you choose this string)
|
|
264
|
+
- `matchValue`: the current selected value from the source field (e.g., `win11`)
|
|
265
|
+
|
|
266
|
+
Your flow must respond back to the same uibuilder instance (uibuilder input) with:
|
|
267
|
+
|
|
268
|
+
```json
|
|
269
|
+
{
|
|
270
|
+
"payload": {
|
|
271
|
+
"type": "lookup:data",
|
|
272
|
+
"lookupId": "osProfiles",
|
|
273
|
+
"matchValue": "win11",
|
|
274
|
+
"items": [ { "value": "win11", "label": "Windows 11", "devices": ["..."], "software": ["..."] } ]
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Notes:
|
|
280
|
+
- `items` must be an **array**. Each item should contain the properties referenced by your field’s mappings.
|
|
281
|
+
- Include the same `matchValue` so the portal can cache responses per selection.
|
|
282
|
+
|
|
283
|
+
## Typical wiring
|
|
284
|
+
|
|
285
|
+
- uibuilder output #1 → Switch (`msg.payload.type == lookup:get`) → Function → uibuilder input
|
|
286
|
+
|
|
287
|
+
Example Function node skeleton:
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
const p = msg.payload || {};
|
|
291
|
+
if (p.type !== "lookup:get") return null;
|
|
292
|
+
if (p.lookupId !== "osProfiles") return null;
|
|
293
|
+
|
|
294
|
+
msg.payload = {
|
|
295
|
+
type: "lookup:data",
|
|
296
|
+
lookupId: p.lookupId,
|
|
297
|
+
matchValue: p.matchValue || null,
|
|
298
|
+
items: [] // return matching record(s) here
|
|
299
|
+
};
|
|
300
|
+
return msg;
|
|
301
|
+
```
|
|
247
302
|
</script>
|
|
248
303
|
|
|
249
304
|
|
|
@@ -455,7 +455,7 @@ module.exports = function(RED) {
|
|
|
455
455
|
await fs.writeJson(targetSchema, schema, { spaces: 2 });
|
|
456
456
|
|
|
457
457
|
const runtimeData = {
|
|
458
|
-
generatorVersion: "0.5.
|
|
458
|
+
generatorVersion: "0.5.17",
|
|
459
459
|
generatorNode: "uibuilder-formgen-v3",
|
|
460
460
|
timestamp: new Date().toISOString(),
|
|
461
461
|
instanceName: instanceName,
|
|
@@ -270,4 +270,36 @@ Generates a uibuilder instance (`src/index.html`, `src/index.js`, `src/form.sche
|
|
|
270
270
|
**Paths**
|
|
271
271
|
- Default: `${userDir}/uibuilder/<instance>/src/`
|
|
272
272
|
- Optional overrides (highest priority first): `instanceRootDir`, `uibRootDir`, `projectName` (see README for details).
|
|
273
|
+
|
|
274
|
+
## Lookup / Auto-fill (static vs dynamic)
|
|
275
|
+
|
|
276
|
+
PortalSmith supports “smart fields” that auto-populate other fields based on a selected value.
|
|
277
|
+
|
|
278
|
+
- **Static (schema.lookups)**: lookup lists live in the schema (`schema.lookups.<name>`). No Node-RED flow changes required.
|
|
279
|
+
- **Dynamic (Node-RED via uibuilder)**: the portal requests data from your flow using uibuilder messages.
|
|
280
|
+
|
|
281
|
+
## Dynamic lookup message contract (Node-RED source)
|
|
282
|
+
|
|
283
|
+
When a field changes (e.g., OS selection), the portal sends to Node-RED (uibuilder output #1):
|
|
284
|
+
|
|
285
|
+
```json
|
|
286
|
+
{ "payload": { "type": "lookup:get", "lookupId": "osProfiles", "matchValue": "win11" } }
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Your flow must respond back to the same uibuilder instance (uibuilder input) with:
|
|
290
|
+
|
|
291
|
+
```json
|
|
292
|
+
{
|
|
293
|
+
"payload": {
|
|
294
|
+
"type": "lookup:data",
|
|
295
|
+
"lookupId": "osProfiles",
|
|
296
|
+
"matchValue": "win11",
|
|
297
|
+
"items": [ { "value": "win11", "label": "Windows 11", "devices": ["..."], "software": ["..."] } ]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Typical wiring
|
|
303
|
+
|
|
304
|
+
- uibuilder output #1 → Switch (`msg.payload.type == lookup:get`) → Function → uibuilder input
|
|
273
305
|
</script>
|
|
@@ -484,7 +484,7 @@ module.exports = function(RED) {
|
|
|
484
484
|
|
|
485
485
|
// Write runtime metadata
|
|
486
486
|
const runtimeData = {
|
|
487
|
-
generatorVersion: "0.5.
|
|
487
|
+
generatorVersion: "0.5.17",
|
|
488
488
|
timestamp: new Date().toISOString(),
|
|
489
489
|
instanceName: instanceName,
|
|
490
490
|
storageMode: storageMode,
|
package/package.json
CHANGED
|
@@ -270,9 +270,39 @@
|
|
|
270
270
|
</div>
|
|
271
271
|
|
|
272
272
|
<div v-if="resultView === 'table'">
|
|
273
|
-
<div v-if="
|
|
273
|
+
<div v-if="showResultPagination" class="d-flex flex-wrap align-items-center justify-content-between mb-2">
|
|
274
|
+
<div class="d-flex align-items-center">
|
|
275
|
+
<span class="text-muted small mr-2">Rows per page</span>
|
|
276
|
+
<select class="custom-select custom-select-sm" style="width:auto;" v-model.number="resultPageSize">
|
|
277
|
+
<option v-for="n in resultPageSizeOptions" :key="'ps-' + n" :value="n">{{n}}</option>
|
|
278
|
+
</select>
|
|
279
|
+
<span class="text-muted small ml-3">
|
|
280
|
+
{{ Math.min(resultPageStart + 1, resultTotalRows) }}-{{ Math.min(resultPageEnd, resultTotalRows) }} of {{ resultTotalRows }}
|
|
281
|
+
</span>
|
|
282
|
+
</div>
|
|
283
|
+
<div class="btn-group btn-group-sm mt-2 mt-md-0" role="group" aria-label="Pagination">
|
|
284
|
+
<button type="button" class="btn btn-outline-secondary" :disabled="resultPage <= 1" @click="resultPage = Math.max(1, resultPage - 1)">Prev</button>
|
|
285
|
+
<button type="button" class="btn btn-outline-secondary" :disabled="resultPage >= resultTotalPages" @click="resultPage = Math.min(resultTotalPages, resultPage + 1)">Next</button>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<div v-if="resultTotalRows === 0" class="text-muted">
|
|
274
290
|
No structured fields to display.
|
|
275
291
|
</div>
|
|
292
|
+
<div v-else-if="resultIsArrayOfObjects" class="table-responsive">
|
|
293
|
+
<table class="table table-sm table-bordered mb-0">
|
|
294
|
+
<thead>
|
|
295
|
+
<tr>
|
|
296
|
+
<th v-for="h in resultRecordHeaders" :key="h">{{h}}</th>
|
|
297
|
+
</tr>
|
|
298
|
+
</thead>
|
|
299
|
+
<tbody>
|
|
300
|
+
<tr v-for="(row, idx) in resultPagedRows" :key="'rr-' + idx">
|
|
301
|
+
<td v-for="h in resultRecordHeaders" :key="'rc-' + idx + '-' + h" style="white-space:pre-wrap;">{{row[h]}}</td>
|
|
302
|
+
</tr>
|
|
303
|
+
</tbody>
|
|
304
|
+
</table>
|
|
305
|
+
</div>
|
|
276
306
|
<div v-else class="table-responsive">
|
|
277
307
|
<table class="table table-sm table-bordered mb-0">
|
|
278
308
|
<thead>
|
|
@@ -282,7 +312,7 @@
|
|
|
282
312
|
</tr>
|
|
283
313
|
</thead>
|
|
284
314
|
<tbody>
|
|
285
|
-
<tr v-for="row in
|
|
315
|
+
<tr v-for="row in resultPagedRows" :key="row.key">
|
|
286
316
|
<th class="font-weight-normal">{{row.label}}</th>
|
|
287
317
|
<td style="white-space:pre-wrap;">{{row.value}}</td>
|
|
288
318
|
</tr>
|
|
@@ -291,7 +321,7 @@
|
|
|
291
321
|
</div>
|
|
292
322
|
</div>
|
|
293
323
|
|
|
294
|
-
<pre v-else class="border rounded p-2 mb-0" style="white-space:pre-wrap;max-height:520px;overflow:auto;">{{
|
|
324
|
+
<pre v-else class="border rounded p-2 mb-0" style="white-space:pre-wrap;max-height:520px;overflow:auto;">{{jsonResultText}}</pre>
|
|
295
325
|
</div>
|
|
296
326
|
</div>
|
|
297
327
|
|
|
@@ -172,7 +172,8 @@
|
|
|
172
172
|
// - lookupId (for "give me the full list" responses)
|
|
173
173
|
// - lookupId::matchValue (for "give me list for this selected value" responses)
|
|
174
174
|
const __psLookupCache = {}; // { [cacheKey]: any[] }
|
|
175
|
-
const __psLookupPending = {}; // { [cacheKey]:
|
|
175
|
+
const __psLookupPending = {}; // { [cacheKey]: number(timestampMs) }
|
|
176
|
+
const __psLookupPendingTimeoutMs = 8000;
|
|
176
177
|
|
|
177
178
|
function getByPath(obj, path) {
|
|
178
179
|
if (!obj) return undefined;
|
|
@@ -267,13 +268,28 @@
|
|
|
267
268
|
return mv ? `${id}::${mv}` : id;
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
function clearLookupPending(lookupId, matchValue) {
|
|
272
|
+
const id = String(lookupId || '').trim();
|
|
273
|
+
if (!id) return;
|
|
274
|
+
const mv = (matchValue === null || matchValue === undefined) ? '' : String(matchValue).trim();
|
|
275
|
+
if (mv) {
|
|
276
|
+
delete __psLookupPending[`${id}::${mv}`];
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// If matchValue isn't provided, clear ALL pending entries for this lookupId (id and id::*)
|
|
280
|
+
Object.keys(__psLookupPending).forEach(k => {
|
|
281
|
+
if (k === id || k.startsWith(id + '::')) delete __psLookupPending[k];
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
270
285
|
function requestLookup(lookupId, matchValue) {
|
|
271
286
|
const id = String(lookupId || '').trim();
|
|
272
287
|
if (!id) return;
|
|
273
288
|
const key = lookupCacheKey(id, matchValue);
|
|
274
289
|
if (__psLookupCache[key]) return;
|
|
275
|
-
|
|
276
|
-
|
|
290
|
+
const pendingAt = __psLookupPending[key];
|
|
291
|
+
if (pendingAt && (Date.now() - pendingAt) < __psLookupPendingTimeoutMs) return;
|
|
292
|
+
__psLookupPending[key] = Date.now();
|
|
277
293
|
if (uibuilderInstance && typeof uibuilderInstance.send === 'function') {
|
|
278
294
|
// matchValue is optional; if provided, Node-RED can respond with items filtered for that value.
|
|
279
295
|
uibuilderInstance.send({ payload: { type: 'lookup:get', lookupId: id, matchValue: (matchValue === undefined ? null : matchValue) } });
|
|
@@ -298,7 +314,9 @@
|
|
|
298
314
|
items = normalizeLookupItems(getByPath(schema, rule.sourcePath));
|
|
299
315
|
} else if (rule.sourceType === 'uibuilder') {
|
|
300
316
|
const key = lookupCacheKey(rule.lookupId, sourceValue);
|
|
301
|
-
|
|
317
|
+
// Always request per-selection so Node-RED can return filtered data for the current value.
|
|
318
|
+
// (Cached per lookupId::matchValue so it only requests once per selected value.)
|
|
319
|
+
requestLookup(rule.lookupId, sourceValue);
|
|
302
320
|
items = normalizeLookupItems(__psLookupCache[key] || __psLookupCache[String(rule.lookupId)] || []);
|
|
303
321
|
}
|
|
304
322
|
|
|
@@ -454,7 +472,8 @@
|
|
|
454
472
|
if (app) {
|
|
455
473
|
try {
|
|
456
474
|
app.__psAutofillRules = compileAutofillRules(schema);
|
|
457
|
-
|
|
475
|
+
// NOTE: Do NOT pre-request dynamic lookups without a selected value.
|
|
476
|
+
// Dynamic lookups are requested per-selection (matchValue) inside applyAutofillRule.
|
|
458
477
|
(app.__psAutofillRules || []).forEach(r => {
|
|
459
478
|
app.$watch(
|
|
460
479
|
function() { return app.formData[r.fromField]; },
|
|
@@ -657,6 +676,9 @@
|
|
|
657
676
|
lastResult: null,
|
|
658
677
|
lastResultStatus: null,
|
|
659
678
|
resultView: 'table',
|
|
679
|
+
resultPage: 1,
|
|
680
|
+
resultPageSize: 15,
|
|
681
|
+
resultPageSizeOptions: [10, 15, 25, 50, 100, 1000],
|
|
660
682
|
copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
|
|
661
683
|
exportFormats: CONFIG.exportFormats
|
|
662
684
|
},
|
|
@@ -684,7 +706,24 @@
|
|
|
684
706
|
return String(this.lastResult);
|
|
685
707
|
}
|
|
686
708
|
},
|
|
687
|
-
|
|
709
|
+
// Result table mode helpers
|
|
710
|
+
resultIsArrayOfObjects() {
|
|
711
|
+
const r = this.lastResult;
|
|
712
|
+
return Array.isArray(r) && r.length && typeof r[0] === 'object' && !Array.isArray(r[0]);
|
|
713
|
+
},
|
|
714
|
+
resultRecordHeaders() {
|
|
715
|
+
if (!this.resultIsArrayOfObjects) return [];
|
|
716
|
+
try { return Object.keys(this.lastResult[0] || {}); } catch (e) { return []; }
|
|
717
|
+
},
|
|
718
|
+
resultRecordRows() {
|
|
719
|
+
if (!this.resultIsArrayOfObjects) return [];
|
|
720
|
+
return (this.lastResult || []).map((row, idx) => {
|
|
721
|
+
const out = { __idx: idx };
|
|
722
|
+
(this.resultRecordHeaders || []).forEach(k => { out[k] = this.formatResultValue(row ? row[k] : ''); });
|
|
723
|
+
return out;
|
|
724
|
+
});
|
|
725
|
+
},
|
|
726
|
+
resultKeyValueRows() {
|
|
688
727
|
const r = this.lastResult;
|
|
689
728
|
if (r === null || r === undefined) return [];
|
|
690
729
|
|
|
@@ -697,14 +736,8 @@
|
|
|
697
736
|
}));
|
|
698
737
|
}
|
|
699
738
|
|
|
700
|
-
// If result is an array, show a single row with joined values
|
|
701
|
-
if (Array.isArray(r)) {
|
|
702
|
-
return [{
|
|
703
|
-
key: '__array__',
|
|
704
|
-
label: 'items',
|
|
705
|
-
value: this.formatResultValue(r),
|
|
706
|
-
}];
|
|
707
|
-
}
|
|
739
|
+
// If result is an array (but not array-of-objects), show a single row with joined values
|
|
740
|
+
if (Array.isArray(r)) return [{ key: '__array__', label: 'items', value: this.formatResultValue(r) }];
|
|
708
741
|
|
|
709
742
|
// Primitive/string result
|
|
710
743
|
return [{
|
|
@@ -712,8 +745,42 @@
|
|
|
712
745
|
label: 'result',
|
|
713
746
|
value: this.formatResultValue(r),
|
|
714
747
|
}];
|
|
748
|
+
},
|
|
749
|
+
resultTotalRows() {
|
|
750
|
+
return this.resultIsArrayOfObjects ? (this.resultRecordRows || []).length : (this.resultKeyValueRows || []).length;
|
|
751
|
+
},
|
|
752
|
+
showResultPagination() {
|
|
753
|
+
return this.resultTotalRows > 10;
|
|
754
|
+
},
|
|
755
|
+
resultTotalPages() {
|
|
756
|
+
const sz = Number(this.resultPageSize) || 15;
|
|
757
|
+
return Math.max(1, Math.ceil(this.resultTotalRows / sz));
|
|
758
|
+
},
|
|
759
|
+
resultPageStart() {
|
|
760
|
+
const sz = Number(this.resultPageSize) || 15;
|
|
761
|
+
return ((Number(this.resultPage) || 1) - 1) * sz;
|
|
762
|
+
},
|
|
763
|
+
resultPageEnd() {
|
|
764
|
+
const sz = Number(this.resultPageSize) || 15;
|
|
765
|
+
return this.resultPageStart + sz;
|
|
766
|
+
},
|
|
767
|
+
resultPagedRows() {
|
|
768
|
+
const rows = this.resultIsArrayOfObjects ? (this.resultRecordRows || []) : (this.resultKeyValueRows || []);
|
|
769
|
+
if (!this.showResultPagination) return rows;
|
|
770
|
+
return rows.slice(this.resultPageStart, this.resultPageEnd);
|
|
771
|
+
},
|
|
772
|
+
jsonResultText() {
|
|
773
|
+
if (Array.isArray(this.lastResult) && this.lastResult.length > 10) {
|
|
774
|
+
const slice = this.lastResult.slice(this.resultPageStart, this.resultPageEnd);
|
|
775
|
+
try { return JSON.stringify(slice, null, 2); } catch (e) { return String(slice); }
|
|
776
|
+
}
|
|
777
|
+
return this.formattedResult;
|
|
715
778
|
}
|
|
716
779
|
},
|
|
780
|
+
watch: {
|
|
781
|
+
lastResult() { this.resultPage = 1; },
|
|
782
|
+
resultPageSize() { this.resultPage = 1; }
|
|
783
|
+
},
|
|
717
784
|
methods: {
|
|
718
785
|
handleClearForm() {
|
|
719
786
|
try {
|
|
@@ -1246,10 +1313,19 @@
|
|
|
1246
1313
|
uibuilderInstance.onChange('msg', function(msg) {
|
|
1247
1314
|
// Normalize message: uibuilder may send msg.payload or msg directly
|
|
1248
1315
|
const payload = msg && (msg.payload ?? msg);
|
|
1249
|
-
if (
|
|
1316
|
+
if (payload === null || payload === undefined) return;
|
|
1250
1317
|
|
|
1318
|
+
function setResult(result, status, preferredView) {
|
|
1319
|
+
if (!app) return;
|
|
1320
|
+
app.lastResultStatus = (status === null || status === undefined) ? null : status;
|
|
1321
|
+
app.lastResult = result;
|
|
1322
|
+
app.showResult = true;
|
|
1323
|
+
app.resultView = preferredView || 'table';
|
|
1324
|
+
app.resultPage = 1;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1251
1327
|
// Handle draft response
|
|
1252
|
-
if (payload.type === 'draft' && payload.formId === CONFIG.formId) {
|
|
1328
|
+
if (payload && typeof payload === 'object' && payload.type === 'draft' && payload.formId === CONFIG.formId) {
|
|
1253
1329
|
if (app && payload.data) {
|
|
1254
1330
|
app.formData = { ...app.formData, ...payload.data };
|
|
1255
1331
|
showAlert('Draft loaded from server', 'success');
|
|
@@ -1257,26 +1333,54 @@
|
|
|
1257
1333
|
}
|
|
1258
1334
|
|
|
1259
1335
|
// Handle submit response
|
|
1260
|
-
if (payload.type === 'submit:ok') {
|
|
1336
|
+
if (payload && typeof payload === 'object' && payload.type === 'submit:ok') {
|
|
1261
1337
|
if (app) {
|
|
1262
|
-
|
|
1263
|
-
app.lastResult = payload.result ?? payload.payload ?? payload;
|
|
1264
|
-
app.showResult = true;
|
|
1265
|
-
app.resultView = 'table';
|
|
1338
|
+
setResult(payload.result ?? payload.payload ?? payload, payload.status || null, 'table');
|
|
1266
1339
|
}
|
|
1267
1340
|
showAlert('Submission confirmed', 'success');
|
|
1268
|
-
} else if (payload.type === 'submit:error') {
|
|
1341
|
+
} else if (payload && typeof payload === 'object' && payload.type === 'submit:error') {
|
|
1269
1342
|
if (app) {
|
|
1270
|
-
|
|
1271
|
-
app.lastResult = payload.result ?? payload.error ?? payload;
|
|
1272
|
-
app.showResult = true;
|
|
1273
|
-
app.resultView = 'json';
|
|
1343
|
+
setResult(payload.result ?? payload.error ?? payload, payload.status || null, 'json');
|
|
1274
1344
|
}
|
|
1275
1345
|
showAlert('Submission failed', 'danger');
|
|
1276
1346
|
}
|
|
1347
|
+
|
|
1348
|
+
// Generic result display (works with uibuilder -> input -> uibuilder patterns)
|
|
1349
|
+
// Supported incoming shapes:
|
|
1350
|
+
// - msg.payload = { type: 'result', result: any, status?: number, view?: 'table'|'json' }
|
|
1351
|
+
// - msg.payload = { result: any } (even without type)
|
|
1352
|
+
// - msg.payload.result = any (common Node-RED pattern)
|
|
1353
|
+
// - msg.payload = any[] (array result directly)
|
|
1354
|
+
// - msg.payload = any (primitive result directly)
|
|
1355
|
+
//
|
|
1356
|
+
// NOTE: This does not interfere with lookup/draft/submit messages since those are handled above.
|
|
1357
|
+
if (app) {
|
|
1358
|
+
// Arrays/primitives: treat as a result payload
|
|
1359
|
+
if (Array.isArray(payload) || (typeof payload !== 'object')) {
|
|
1360
|
+
setResult(payload, null, Array.isArray(payload) ? 'table' : 'json');
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
// Objects: accept {result: ...} or explicit type variants
|
|
1364
|
+
if (payload && typeof payload === 'object') {
|
|
1365
|
+
const t = String(payload.type || '').trim();
|
|
1366
|
+
const isExplicitResultType = (t === 'result' || t === 'result:data' || t === 'result:show' || t === 'display:result');
|
|
1367
|
+
const hasResultField = Object.prototype.hasOwnProperty.call(payload, 'result');
|
|
1368
|
+
if (isExplicitResultType || hasResultField) {
|
|
1369
|
+
const view = (payload.view === 'json' || payload.view === 'table') ? payload.view : 'table';
|
|
1370
|
+
setResult(payload.result, payload.status || null, view);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
// Also accept nested: { payload: { result: ... } } if user forwards whole msg objects
|
|
1374
|
+
if (payload.payload && typeof payload.payload === 'object' && Object.prototype.hasOwnProperty.call(payload.payload, 'result')) {
|
|
1375
|
+
const view = (payload.payload.view === 'json' || payload.payload.view === 'table') ? payload.payload.view : 'table';
|
|
1376
|
+
setResult(payload.payload.result, payload.payload.status || payload.status || null, view);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1277
1381
|
|
|
1278
1382
|
// Handle export response
|
|
1279
|
-
if (payload.type === 'export:file') {
|
|
1383
|
+
if (payload && typeof payload === 'object' && payload.type === 'export:file') {
|
|
1280
1384
|
const fileInfo = payload.payload || payload;
|
|
1281
1385
|
const format = fileInfo.format || 'file';
|
|
1282
1386
|
if (fileInfo.url) {
|
|
@@ -1289,21 +1393,19 @@
|
|
|
1289
1393
|
}
|
|
1290
1394
|
|
|
1291
1395
|
// Handle lookup responses (for auto-fill)
|
|
1292
|
-
if (payload.type === 'lookup:data' && payload.lookupId) {
|
|
1396
|
+
if (payload && typeof payload === 'object' && payload.type === 'lookup:data' && payload.lookupId) {
|
|
1293
1397
|
const mv = (payload.matchValue === null || payload.matchValue === undefined) ? '' : String(payload.matchValue).trim();
|
|
1294
1398
|
const key = mv ? `${String(payload.lookupId)}::${mv}` : String(payload.lookupId);
|
|
1295
1399
|
__psLookupCache[key] = normalizeLookupItems(payload.items);
|
|
1296
|
-
|
|
1400
|
+
clearLookupPending(payload.lookupId, payload.matchValue);
|
|
1297
1401
|
if (app && Array.isArray(app.__psAutofillRules)) {
|
|
1298
1402
|
app.__psAutofillRules
|
|
1299
1403
|
.filter(r => r.sourceType === 'uibuilder' && r.lookupId === String(payload.lookupId))
|
|
1300
1404
|
.forEach(r => applyAutofillRule(app, app.schema, r));
|
|
1301
1405
|
}
|
|
1302
1406
|
}
|
|
1303
|
-
if (payload.type === 'lookup:error' && payload.lookupId) {
|
|
1304
|
-
|
|
1305
|
-
const key = mv ? `${String(payload.lookupId)}::${mv}` : String(payload.lookupId);
|
|
1306
|
-
delete __psLookupPending[key];
|
|
1407
|
+
if (payload && typeof payload === 'object' && payload.type === 'lookup:error' && payload.lookupId) {
|
|
1408
|
+
clearLookupPending(payload.lookupId, payload.matchValue);
|
|
1307
1409
|
showAlert('Lookup error: ' + (payload.error || 'unknown'), 'warning');
|
|
1308
1410
|
}
|
|
1309
1411
|
});
|
|
@@ -246,6 +246,22 @@
|
|
|
246
246
|
<button type="button" class="btn btn-sm" :class="resultView === 'json' ? 'btn-primary' : 'btn-outline-primary'" @click="resultView = 'json'">JSON</button>
|
|
247
247
|
</div>
|
|
248
248
|
|
|
249
|
+
<div v-if="showResultPagination" class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
|
250
|
+
<div class="d-flex align-items-center gap-2">
|
|
251
|
+
<span class="text-muted small">Rows per page</span>
|
|
252
|
+
<select class="form-select form-select-sm" style="width:auto;" v-model.number="resultPageSize" @change="resultPage = 1">
|
|
253
|
+
<option v-for="n in resultPageSizeOptions" :key="'ps-' + n" :value="n">{{n}}</option>
|
|
254
|
+
</select>
|
|
255
|
+
<span class="text-muted small">
|
|
256
|
+
{{ Math.min(resultPageStart + 1, resultTotalRows) }}-{{ Math.min(resultPageEnd, resultTotalRows) }} of {{ resultTotalRows }}
|
|
257
|
+
</span>
|
|
258
|
+
</div>
|
|
259
|
+
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
|
|
260
|
+
<button type="button" class="btn btn-outline-secondary" :disabled="resultPage <= 1" @click="resultPage = Math.max(1, resultPage - 1)">Prev</button>
|
|
261
|
+
<button type="button" class="btn btn-outline-secondary" :disabled="resultPage >= resultTotalPages" @click="resultPage = Math.min(resultTotalPages, resultPage + 1)">Next</button>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
249
265
|
<div v-if="resultView === 'table'">
|
|
250
266
|
<div v-if="formattedResult && formattedResult.length" class="table-responsive">
|
|
251
267
|
<table class="table table-bordered table-sm">
|
|
@@ -255,7 +271,7 @@
|
|
|
255
271
|
</tr>
|
|
256
272
|
</thead>
|
|
257
273
|
<tbody>
|
|
258
|
-
<tr v-for="(row, idx) in
|
|
274
|
+
<tr v-for="(row, idx) in pagedFormattedResult" :key="'r-' + idx">
|
|
259
275
|
<td v-for="h in resultHeaders" :key="'c-' + idx + '-' + h">{{row[h]}}</td>
|
|
260
276
|
</tr>
|
|
261
277
|
</tbody>
|
|
@@ -264,7 +280,7 @@
|
|
|
264
280
|
<div v-else class="text-muted">No table-friendly data found.</div>
|
|
265
281
|
</div>
|
|
266
282
|
|
|
267
|
-
<pre v-else class="mt-2"><code>{{
|
|
283
|
+
<pre v-else class="mt-2"><code>{{ lastResultPrettyPaged }}</code></pre>
|
|
268
284
|
</div>
|
|
269
285
|
</div>
|
|
270
286
|
|
|
@@ -103,7 +103,8 @@
|
|
|
103
103
|
// - lookupId (for "give me the full list" responses)
|
|
104
104
|
// - lookupId::matchValue (for "give me list for this selected value" responses)
|
|
105
105
|
const __psLookupCache = {}; // { [cacheKey]: any[] }
|
|
106
|
-
const __psLookupPending = {}; // { [cacheKey]:
|
|
106
|
+
const __psLookupPending = {}; // { [cacheKey]: number(timestampMs) }
|
|
107
|
+
const __psLookupPendingTimeoutMs = 8000;
|
|
107
108
|
|
|
108
109
|
function isNonEmptyString(v) {
|
|
109
110
|
return typeof v === 'string' && v.trim().length > 0;
|
|
@@ -208,13 +209,27 @@
|
|
|
208
209
|
return mv ? `${id}::${mv}` : id;
|
|
209
210
|
}
|
|
210
211
|
|
|
212
|
+
function clearLookupPending(lookupId, matchValue) {
|
|
213
|
+
const id = String(lookupId || '').trim();
|
|
214
|
+
if (!id) return;
|
|
215
|
+
const mv = (matchValue === null || matchValue === undefined) ? '' : String(matchValue).trim();
|
|
216
|
+
if (mv) {
|
|
217
|
+
delete __psLookupPending[`${id}::${mv}`];
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
Object.keys(__psLookupPending).forEach(k => {
|
|
221
|
+
if (k === id || k.startsWith(id + '::')) delete __psLookupPending[k];
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
211
225
|
function requestLookup(lookupId, matchValue) {
|
|
212
226
|
const id = String(lookupId || '').trim();
|
|
213
227
|
if (!id) return;
|
|
214
228
|
const key = lookupCacheKey(id, matchValue);
|
|
215
229
|
if (__psLookupCache[key]) return;
|
|
216
|
-
|
|
217
|
-
|
|
230
|
+
const pendingAt = __psLookupPending[key];
|
|
231
|
+
if (pendingAt && (Date.now() - pendingAt) < __psLookupPendingTimeoutMs) return;
|
|
232
|
+
__psLookupPending[key] = Date.now();
|
|
218
233
|
if (uibuilderInstance && typeof uibuilderInstance.send === 'function') {
|
|
219
234
|
// Expected Node-RED response: msg.payload = { type:'lookup:data', lookupId:'...', items:[...] }
|
|
220
235
|
uibuilderInstance.send({ payload: { type: 'lookup:get', lookupId: id, matchValue: (matchValue === undefined ? null : matchValue) } });
|
|
@@ -240,7 +255,9 @@
|
|
|
240
255
|
items = normalizeLookupItems(getByPath(schema, rule.sourcePath));
|
|
241
256
|
} else if (rule.sourceType === 'uibuilder') {
|
|
242
257
|
const key = lookupCacheKey(rule.lookupId, sourceValue);
|
|
243
|
-
|
|
258
|
+
// Always request per-selection so Node-RED can return filtered data for the current value.
|
|
259
|
+
// (Cached per lookupId::matchValue so it only requests once per selected value.)
|
|
260
|
+
requestLookup(rule.lookupId, sourceValue);
|
|
244
261
|
items = normalizeLookupItems(__psLookupCache[key] || __psLookupCache[String(rule.lookupId)] || []);
|
|
245
262
|
}
|
|
246
263
|
|
|
@@ -289,15 +306,23 @@
|
|
|
289
306
|
function setupUibuilderHandlers() {
|
|
290
307
|
if (!uibuilderInstance || !uibuilderInstance.onChange) return;
|
|
291
308
|
uibuilderInstance.onChange('msg', function(msg) {
|
|
292
|
-
const payload = msg && msg.payload;
|
|
293
|
-
if (
|
|
309
|
+
const payload = msg && (msg.payload ?? msg);
|
|
310
|
+
if (payload === null || payload === undefined) return;
|
|
294
311
|
if (!app) return;
|
|
295
312
|
|
|
313
|
+
function setResult(result, status, preferredView) {
|
|
314
|
+
app.lastResultStatus = (status === null || status === undefined) ? null : status;
|
|
315
|
+
app.lastResult = result;
|
|
316
|
+
app.showResult = true;
|
|
317
|
+
app.resultView = preferredView || 'table';
|
|
318
|
+
app.resultPage = 1;
|
|
319
|
+
}
|
|
320
|
+
|
|
296
321
|
if (payload.type === 'lookup:data' && payload.lookupId) {
|
|
297
322
|
const mv = (payload.matchValue === null || payload.matchValue === undefined) ? '' : String(payload.matchValue).trim();
|
|
298
323
|
const key = mv ? `${String(payload.lookupId)}::${mv}` : String(payload.lookupId);
|
|
299
324
|
__psLookupCache[key] = normalizeLookupItems(payload.items);
|
|
300
|
-
|
|
325
|
+
clearLookupPending(payload.lookupId, payload.matchValue);
|
|
301
326
|
// Re-apply any rules using this lookup
|
|
302
327
|
if (Array.isArray(app.__psAutofillRules)) {
|
|
303
328
|
app.__psAutofillRules
|
|
@@ -306,9 +331,7 @@
|
|
|
306
331
|
}
|
|
307
332
|
}
|
|
308
333
|
if (payload.type === 'lookup:error' && payload.lookupId) {
|
|
309
|
-
|
|
310
|
-
const key = mv ? `${String(payload.lookupId)}::${mv}` : String(payload.lookupId);
|
|
311
|
-
delete __psLookupPending[key];
|
|
334
|
+
clearLookupPending(payload.lookupId, payload.matchValue);
|
|
312
335
|
showAlert(app, 'Lookup error: ' + (payload.error || 'unknown'), 'warning');
|
|
313
336
|
}
|
|
314
337
|
|
|
@@ -318,6 +341,29 @@
|
|
|
318
341
|
if (payload.type === 'submit:ok' || payload.type === 'submit:error') {
|
|
319
342
|
app.handleSubmitResponse(payload);
|
|
320
343
|
}
|
|
344
|
+
|
|
345
|
+
// Generic result display (mirrors Vue2 template)
|
|
346
|
+
// - msg.payload = { type:'result', result:any, status?:number, view?:'table'|'json' }
|
|
347
|
+
// - msg.payload = { result:any }
|
|
348
|
+
// - msg.payload.result = any
|
|
349
|
+
// - msg.payload = any[] / primitive
|
|
350
|
+
if (Array.isArray(payload) || (typeof payload !== 'object')) {
|
|
351
|
+
setResult(payload, null, Array.isArray(payload) ? 'table' : 'json');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const t = String(payload.type || '').trim();
|
|
355
|
+
const isExplicitResultType = (t === 'result' || t === 'result:data' || t === 'result:show' || t === 'display:result');
|
|
356
|
+
const hasResultField = Object.prototype.hasOwnProperty.call(payload, 'result');
|
|
357
|
+
if (isExplicitResultType || hasResultField) {
|
|
358
|
+
const view = (payload.view === 'json' || payload.view === 'table') ? payload.view : 'table';
|
|
359
|
+
setResult(payload.result, payload.status || null, view);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (payload.payload && typeof payload.payload === 'object' && Object.prototype.hasOwnProperty.call(payload.payload, 'result')) {
|
|
363
|
+
const view = (payload.payload.view === 'json' || payload.payload.view === 'table') ? payload.payload.view : 'table';
|
|
364
|
+
setResult(payload.payload.result, payload.payload.status || payload.status || null, view);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
321
367
|
});
|
|
322
368
|
}
|
|
323
369
|
|
|
@@ -370,6 +416,9 @@
|
|
|
370
416
|
lastResult: null,
|
|
371
417
|
lastResultStatus: null,
|
|
372
418
|
resultView: 'table',
|
|
419
|
+
resultPage: 1,
|
|
420
|
+
resultPageSize: 15,
|
|
421
|
+
resultPageSizeOptions: [10, 15, 25, 50, 100, 1000],
|
|
373
422
|
copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
|
|
374
423
|
exportFormats: CONFIG.exportFormats
|
|
375
424
|
};
|
|
@@ -406,6 +455,38 @@
|
|
|
406
455
|
}
|
|
407
456
|
// Single object
|
|
408
457
|
return [this.flattenRow(r)];
|
|
458
|
+
},
|
|
459
|
+
resultTotalRows() {
|
|
460
|
+
return (this.formattedResult || []).length;
|
|
461
|
+
},
|
|
462
|
+
showResultPagination() {
|
|
463
|
+
// Show pager when records exceed 10
|
|
464
|
+
return this.resultTotalRows > 10;
|
|
465
|
+
},
|
|
466
|
+
resultTotalPages() {
|
|
467
|
+
const sz = Number(this.resultPageSize) || 15;
|
|
468
|
+
return Math.max(1, Math.ceil(this.resultTotalRows / sz));
|
|
469
|
+
},
|
|
470
|
+
resultPageStart() {
|
|
471
|
+
const sz = Number(this.resultPageSize) || 15;
|
|
472
|
+
return ((Number(this.resultPage) || 1) - 1) * sz;
|
|
473
|
+
},
|
|
474
|
+
resultPageEnd() {
|
|
475
|
+
const sz = Number(this.resultPageSize) || 15;
|
|
476
|
+
return this.resultPageStart + sz;
|
|
477
|
+
},
|
|
478
|
+
pagedFormattedResult() {
|
|
479
|
+
const rows = this.formattedResult || [];
|
|
480
|
+
if (!this.showResultPagination) return rows;
|
|
481
|
+
return rows.slice(this.resultPageStart, this.resultPageEnd);
|
|
482
|
+
},
|
|
483
|
+
lastResultPrettyPaged() {
|
|
484
|
+
// For JSON view: if lastResult is a large array, show a paged slice
|
|
485
|
+
if (Array.isArray(this.lastResult) && this.lastResult.length > 10) {
|
|
486
|
+
const slice = this.lastResult.slice(this.resultPageStart, this.resultPageEnd);
|
|
487
|
+
try { return JSON.stringify(slice, null, 2); } catch (e) { return String(slice); }
|
|
488
|
+
}
|
|
489
|
+
return this.lastResultPretty;
|
|
409
490
|
}
|
|
410
491
|
},
|
|
411
492
|
methods: {
|
|
@@ -551,6 +632,8 @@
|
|
|
551
632
|
this.lastResultStatus = resp.status;
|
|
552
633
|
this.lastResult = (json !== null) ? json : text;
|
|
553
634
|
this.showResult = true;
|
|
635
|
+
this.resultView = resp.ok ? 'table' : 'json';
|
|
636
|
+
this.resultPage = 1;
|
|
554
637
|
showAlert(this, resp.ok ? 'Submitted' : 'Submit error', resp.ok ? 'success' : 'danger');
|
|
555
638
|
return;
|
|
556
639
|
}
|
|
@@ -571,6 +654,8 @@
|
|
|
571
654
|
this.lastResultStatus = payload.status || null;
|
|
572
655
|
this.lastResult = payload.result;
|
|
573
656
|
this.showResult = true;
|
|
657
|
+
this.resultView = (payload.type === 'submit:error') ? 'json' : 'table';
|
|
658
|
+
this.resultPage = 1;
|
|
574
659
|
showAlert(this, payload.type === 'submit:ok' ? 'Submission OK' : 'Submission error', payload.type === 'submit:ok' ? 'success' : 'danger');
|
|
575
660
|
},
|
|
576
661
|
handleExport(format) {
|
|
@@ -607,8 +692,8 @@
|
|
|
607
692
|
// Install lookup/autofill rules after mount (dynamic watchers)
|
|
608
693
|
try {
|
|
609
694
|
app.__psAutofillRules = compileAutofillRules(schema);
|
|
610
|
-
//
|
|
611
|
-
|
|
695
|
+
// NOTE: Do NOT pre-request dynamic lookups without a selected value.
|
|
696
|
+
// Dynamic lookups are requested per-selection (matchValue) inside applyAutofillRule.
|
|
612
697
|
(app.__psAutofillRules || []).forEach(r => {
|
|
613
698
|
app.$watch(
|
|
614
699
|
() => app.formData[r.fromField],
|