@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 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).
@@ -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
- <p>When a portal needs a dynamic list, it sends a message to Node-RED via uibuilder:</p>
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
- <p>Your flow should return the list back to the portal via uibuilder as:</p>
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: connect uibuilder output → Function → uibuilder input.
543
- It responds to <code>lookup:get</code> requests with a static list (replace with DB/API/file lookup as needed).
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.14",
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.14",
487
+ generatorVersion: "0.5.17",
488
488
  timestamp: new Date().toISOString(),
489
489
  instanceName: instanceName,
490
490
  storageMode: storageMode,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyprnet/node-red-contrib-uibuilder-formgen",
3
- "version": "0.5.14",
3
+ "version": "0.5.17",
4
4
  "description": "PortalSmith: Generate schema-driven uibuilder form portals from JSON",
5
5
  "keywords": [
6
6
  "node-red",
@@ -270,9 +270,39 @@
270
270
  </div>
271
271
 
272
272
  <div v-if="resultView === 'table'">
273
- <div v-if="resultRows.length === 0" class="text-muted">
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 resultRows" :key="row.key">
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;">{{formattedResult}}</pre>
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]: true }
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
- if (__psLookupPending[key]) return;
276
- __psLookupPending[key] = true;
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
- if (!__psLookupCache[key]) requestLookup(rule.lookupId, sourceValue);
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
- (app.__psAutofillRules || []).forEach(r => { if (r.sourceType === 'uibuilder') requestLookup(r.lookupId); });
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
- resultRows() {
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 (!payload || typeof payload !== 'object') return;
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
- app.lastResultStatus = payload.status || null;
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
- app.lastResultStatus = payload.status || null;
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
- delete __psLookupPending[key];
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
- const mv = (payload.matchValue === null || payload.matchValue === undefined) ? '' : String(payload.matchValue).trim();
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 formattedResult" :key="'r-' + idx">
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>{{ lastResultPretty }}</code></pre>
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]: true }
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
- if (__psLookupPending[key]) return;
217
- __psLookupPending[key] = true;
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
- if (!__psLookupCache[key]) requestLookup(rule.lookupId, sourceValue);
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 (!payload || typeof payload !== 'object') return;
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
- delete __psLookupPending[key];
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
- const mv = (payload.matchValue === null || payload.matchValue === undefined) ? '' : String(payload.matchValue).trim();
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
- // Pre-request dynamic lookup sources so lookups are ready
611
- (app.__psAutofillRules || []).forEach(r => { if (r.sourceType === 'uibuilder') requestLookup(r.lookupId); });
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],