@cyprnet/node-red-contrib-uibuilder-formgen 0.5.25 → 0.5.27

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
@@ -35,6 +35,14 @@ All notable changes to this package will be documented in this file.
35
35
 
36
36
  - Lookup/Auto-fill: allow textarea targets to accept arrays of <code>{value,label}</code> items by joining the values with newlines.
37
37
 
38
+ ## 0.5.26
39
+
40
+ - UI: widen desktop portal layout (while keeping mobile-friendly behavior) and prefer horizontal scrolling over wrapping for results tables on larger screens.
41
+
42
+ ## 0.5.27
43
+
44
+ - Results view: added column sorting (click header), search across all columns, and export of filtered results to JSON/CSV (Table/JSON views).
45
+
38
46
  ## 0.5.21
39
47
 
40
48
  - Legacy uibuilder 2.x: fixed vendor library paths (<code>../vendor/...</code>) in the uib2 portal template and uib2 Schema Builder so uibuilder comms work and <code>lookup:get</code> messages emit correctly.
@@ -516,7 +516,7 @@
516
516
  <pre>{
517
517
  "lookups": {
518
518
  "deviceTemplates": [
519
- { "name": "Azure Device Template", "id": "tmpl_123", "desc": "..." }
519
+ { "name": "Template A", "id": "tmpl_123", "desc": "..." }
520
520
  ]
521
521
  },
522
522
  "sections": [{
@@ -551,12 +551,12 @@
551
551
  <p>Use this for OS profiles, templates, multi-field mappings, etc.</p>
552
552
  <pre>{
553
553
  "lookups": {
554
- "osProfiles": [
554
+ "profiles": [
555
555
  {
556
- "value": "win11",
557
- "label": "Windows 11",
558
- "devices": ["Dell 123", "Dell 321"],
559
- "software": ["M365 Apps", "Teams"]
556
+ "value": "profileA",
557
+ "label": "Profile A",
558
+ "devices": ["Device 1", "Device 2"],
559
+ "software": ["App 1", "App 2"]
560
560
  }
561
561
  ]
562
562
  }
@@ -597,7 +597,7 @@
597
597
  "label": "Template ID",
598
598
  "autoFill": {
599
599
  "fromField": "templateName",
600
- "source": { "type": "uibuilder", "lookupId": "deviceTemplates" },
600
+ "source": { "type": "uibuilder", "lookupId": "templates" },
601
601
  "matchKey": "name",
602
602
  "mappings": [{ "targetField": "templateId", "valueKey": "id" }]
603
603
  }
@@ -608,7 +608,7 @@
608
608
  <h5>Request message (portal → Node-RED)</h5>
609
609
  <p>When a user changes the source field, the portal sends a request to Node-RED via uibuilder (output #1):</p>
610
610
  <pre>{
611
- "payload": { "type": "lookup:get", "lookupId": "deviceTemplates", "matchValue": "win11" }
611
+ "payload": { "type": "lookup:get", "lookupId": "templates", "matchValue": "Template A" }
612
612
  }</pre>
613
613
  <ul>
614
614
  <li><strong>lookupId</strong>: identifies which lookup list/service you want (you define this string)</li>
@@ -618,7 +618,7 @@
618
618
  <h5>Response message (Node-RED → portal)</h5>
619
619
  <p>Your flow should return the list back to the portal via the same uibuilder node’s input:</p>
620
620
  <pre>{
621
- "payload": { "type": "lookup:data", "lookupId": "deviceTemplates", "matchValue": "win11", "items": [ ... ] }
621
+ "payload": { "type": "lookup:data", "lookupId": "templates", "matchValue": "Template A", "items": [ ... ] }
622
622
  }</pre>
623
623
  <div class="note ok">
624
624
  <strong>Important:</strong> Return <code>items</code> as an <strong>array</strong>. Each item must contain the properties your mappings reference.
@@ -631,7 +631,7 @@
631
631
  </div>
632
632
  <p>If something fails, you can return:</p>
633
633
  <pre>{
634
- "payload": { "type": "lookup:error", "lookupId": "deviceTemplates", "error": "reason here" }
634
+ "payload": { "type": "lookup:error", "lookupId": "templates", "error": "reason here" }
635
635
  }</pre>
636
636
 
637
637
  <h4>Minimal dynamic lookup handler (Function node example)</h4>
@@ -645,15 +645,15 @@ const p = msg.payload || {};
645
645
  if (p.type !== "lookup:get") return null;
646
646
 
647
647
  // Switch on lookupId so you can serve multiple lists
648
- if (p.lookupId === "deviceTemplates") {
648
+ if (p.lookupId === "templates") {
649
649
  // Optional: use p.matchValue to return only the records relevant to the current selection
650
650
  msg.payload = {
651
651
  type: "lookup:data",
652
652
  lookupId: p.lookupId,
653
653
  matchValue: p.matchValue || null,
654
654
  items: [
655
- { name: "Azure Device Template", id: "tmpl_123", desc: "Example" },
656
- { name: "Azure Block Template", id: "tmpl_456", desc: "Example" }
655
+ { name: "Template A", id: "tmpl_123", desc: "Example" },
656
+ { name: "Template B", id: "tmpl_456", desc: "Example" }
657
657
  ]
658
658
  };
659
659
  return msg;
@@ -669,12 +669,104 @@ return msg;</pre>
669
669
  <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>
670
670
  </ul>
671
671
 
672
+ <h4>Lookup / Auto-fill cookbook (schema ↔ payload)</h4>
673
+ <p>
674
+ Use this as a “fill in the blanks” guide. Start from what you want to populate, then set your schema fields accordingly and return the matching
675
+ <code>lookup:data</code> shape from Node-RED.
676
+ </p>
677
+ <div class="note ok">
678
+ <strong>Golden rule:</strong> <code>payload.items</code> must be an <strong>array</strong> of records. Even if you only have one record to return, wrap it as <code>items: [ { ... } ]</code>.
679
+ </div>
680
+
681
+ <table>
682
+ <thead>
683
+ <tr>
684
+ <th>Goal</th>
685
+ <th>Schema settings (source field)</th>
686
+ <th>What a matched item must contain</th>
687
+ </tr>
688
+ </thead>
689
+ <tbody>
690
+ <tr>
691
+ <td><strong>Select “OS” fills 2 textareas</strong><br/><span class="pill">multi-field mapping</span></td>
692
+ <td>
693
+ <div><strong>fromField</strong>: <code>os</code></div>
694
+ <div><strong>lookupId</strong>: <code>profiles</code></div>
695
+ <div><strong>matchKey</strong>: <code>value</code></div>
696
+ <div><strong>mappings</strong>:
697
+ <ul>
698
+ <li><code>{ targetField:"devicesText", valueKey:"devices" }</code></li>
699
+ <li><code>{ targetField:"softwareText", valueKey:"software" }</code></li>
700
+ </ul>
701
+ </div>
702
+ </td>
703
+ <td>
704
+ <div><strong>value</strong> (or whatever your <code>matchKey</code> is set to)</div>
705
+ <div><strong>devices</strong> and <strong>software</strong> as arrays of strings (recommended)</div>
706
+ </td>
707
+ </tr>
708
+ <tr>
709
+ <td><strong>Subnet/CIDR fills a textarea with an IP list</strong><br/><span class="pill">bulk list → textarea</span></td>
710
+ <td>
711
+ <div><strong>fromField</strong>: <code>subnetAddress</code></div>
712
+ <div><strong>lookupId</strong>: <code>ips</code></div>
713
+ <div><strong>matchKey</strong>: <code>ip</code></div>
714
+ <div><strong>mappings</strong>: <code>{ targetField:"iplist", valueKey:"ips" }</code></div>
715
+ </td>
716
+ <td>
717
+ <div><strong>ip</strong> equals the typed subnet/CIDR (exact match)</div>
718
+ <div><strong>ips</strong> is either:
719
+ <ul>
720
+ <li><strong>array of strings</strong>: <code>["192.168.1.0","192.168.1.1",...]</code> (best)</li>
721
+ <li><strong>array of objects</strong>: <code>[{"value":"192.168.1.0","label":"..."},...]</code> (textarea will join by <code>value</code>/<code>label</code>)</li>
722
+ </ul>
723
+ </div>
724
+ </td>
725
+ </tr>
726
+ </tbody>
727
+ </table>
728
+
729
+ <h5>Example: Subnet/CIDR → textarea list (dynamic lookup)</h5>
730
+ <p><strong>Schema (field):</strong></p>
731
+ <pre>{
732
+ "id": "subnetAddress",
733
+ "type": "text",
734
+ "label": "Subnet Address",
735
+ "autoFill": {
736
+ "fromField": "subnetAddress",
737
+ "source": { "type": "uibuilder", "lookupId": "ips" },
738
+ "matchKey": "ip",
739
+ "clearOnNoMatch": true,
740
+ "mappings": [
741
+ { "targetField": "iplist", "valueKey": "ips" }
742
+ ]
743
+ }
744
+ }</pre>
745
+ <p><strong>Node-RED response:</strong></p>
746
+ <pre>{
747
+ "payload": {
748
+ "type": "lookup:data",
749
+ "lookupId": "ips",
750
+ "matchValue": "192.168.1.0/24",
751
+ "items": [
752
+ {
753
+ "ip": "192.168.1.0/24",
754
+ "ips": ["192.168.1.0", "192.168.1.1", "192.168.1.2"]
755
+ }
756
+ ]
757
+ }
758
+ }</pre>
759
+ <div class="note">
760
+ <strong>Why your “items as an object” fails:</strong> if you return <code>items</code> as a single object (not an array), it is treated like a dictionary and won’t contain an <code>ip</code> property at the top level for matching.
761
+ </div>
762
+
672
763
  <h4>Common pitfalls</h4>
673
764
  <ul>
674
765
  <li><strong>Wrong lookupId</strong>: request and response lookupId must match exactly.</li>
675
766
  <li><strong>Missing matchValue in response</strong>: include it so caching works reliably.</li>
676
- <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>
767
+ <li><strong>matchKey mismatch</strong>: if your select uses values like <code>profileA</code>, set <code>matchKey</code> to <code>value</code> (not <code>label</code>).</li>
677
768
  <li><strong>valueKey mismatch</strong>: ensure the returned item contains the properties your mappings reference.</li>
769
+ <li><strong>items must be an array</strong>: even for a single record, return <code>items: [ { ... } ]</code>.</li>
678
770
  </ul>
679
771
 
680
772
  <h4>Schema Builder: how to configure Lookup / Auto-fill</h4>
@@ -706,7 +798,7 @@ return msg;</pre>
706
798
  <li><strong>Unexpected clearing</strong>: check <code>clearOnNoMatch</code> and whether the source field becomes blank during edits.</li>
707
799
  </ul>
708
800
  <div class="note ok">
709
- <strong>Tip (arrays are easier):</strong> For long lists, store them as <strong>arrays</strong> in your lookup items (e.g. <code>"devices": ["Dell...","HP..."]</code>)
801
+ <strong>Tip (arrays are easier):</strong> For long lists, store them as <strong>arrays</strong> in your lookup items (e.g. <code>"devices": ["Device 1","Device 2"]</code>)
710
802
  instead of a single string with embedded newlines. When you map an array of strings into a <code>textarea</code> (or keyvalue delimiter field),
711
803
  the generated portal will automatically join the array into lines for display.
712
804
  </div>
@@ -805,9 +897,75 @@ return msg;</pre>
805
897
  <h2>Submitting & results page</h2>
806
898
  <p>After Submit, the portal shows a Result page with:</p>
807
899
  <ul>
808
- <li><strong>Table view</strong> and <strong>JSON view</strong> toggle</li>
809
- <li><strong>Download JSON</strong></li>
900
+ <li><strong>Table</strong>, <strong>JSON</strong>, and <strong>Form</strong> view toggle</li>
901
+ <li><strong>Client-side pagination</strong> for large arrays (Table/JSON views)</li>
902
+ <li><strong>Download JSON</strong> (and <strong>Download Record JSON</strong> in Form view)</li>
810
903
  </ul>
904
+ <h3>Submit message contract (uibuilder mode)</h3>
905
+ <p>
906
+ When <code>submitMode</code> is <strong>uibuilder</strong>, the portal sends a submit message to Node-RED via the uibuilder node (output #1).
907
+ Your flow should respond back to the same uibuilder node’s input with a submit response and/or a result payload to display.
908
+ </p>
909
+
910
+ <h4>Submit request (portal → Node-RED)</h4>
911
+ <pre>{
912
+ "payload": {
913
+ "type": "submit",
914
+ "formId": "your_form_id",
915
+ "payload": { "fieldA": "value", "fieldB": 123 },
916
+ "meta": {
917
+ "timestamp": "2026-01-01T12:00:00.000Z",
918
+ "userAgent": "browser UA",
919
+ "url": "http://host/uibuilder/instance/"
920
+ }
921
+ }
922
+ }</pre>
923
+
924
+ <h4>Submit response (Node-RED → portal)</h4>
925
+ <p>Respond with one of the following (these also clear the submit spinner):</p>
926
+ <pre>{
927
+ "payload": {
928
+ "type": "submit:ok",
929
+ "message": "Saved successfully",
930
+ "result": { "id": "123", "status": "ok" }
931
+ }
932
+ }</pre>
933
+ <pre>{
934
+ "payload": {
935
+ "type": "submit:error",
936
+ "message": "Validation failed",
937
+ "error": { "details": "..." }
938
+ }
939
+ }</pre>
940
+
941
+ <div class="note ok">
942
+ <strong>Result display:</strong> The portal will display <code>payload.result</code> (or, if you send a raw result message, it will display <code>payload.result</code> / <code>payload.payload.result</code> / an array payload).
943
+ </div>
944
+
945
+ <h3>Returning results to display (table/json/form)</h3>
946
+ <p>
947
+ To show a table with pagination, return an <strong>array of objects</strong> in <code>payload.result</code>:
948
+ </p>
949
+ <pre>{
950
+ "payload": {
951
+ "type": "submit:ok",
952
+ "result": [
953
+ { "ip": "192.168.1.0", "status": "free" },
954
+ { "ip": "192.168.1.1", "status": "used" }
955
+ ]
956
+ }
957
+ }</pre>
958
+ <p>
959
+ You can also send a “generic result” message (not tied to submit), and the portal will still display it:
960
+ </p>
961
+ <pre>{
962
+ "payload": {
963
+ "result": [
964
+ { "ip": "192.168.1.0", "status": "free" }
965
+ ]
966
+ }
967
+ }</pre>
968
+
811
969
  <p>Table view formatting:</p>
812
970
  <ul>
813
971
  <li>Top-level object keys show as rows</li>
@@ -297,6 +297,60 @@ It generates a portal that loads the uibuilder 2.x vendor stack (Socket.IO + Vue
297
297
 
298
298
  **Note**
299
299
  - This node does not change the behavior of `uibuilder-formgen` (Vue 2 + uibuilder v7) or `uibuilder-formgen-v3` (Vue 3 + Bootstrap 5).
300
+
301
+ ## Lookup / Auto-fill (static vs dynamic)
302
+
303
+ PortalSmith supports “smart fields” that auto-populate other fields based on a selected/typed value.
304
+
305
+ - **Static (schema.lookups)**: lookup lists live in the schema (`schema.lookups.<name>`). No Node-RED flow changes required.
306
+ - **Dynamic (Node-RED via uibuilder)**: the portal requests data from your flow using uibuilder messages.
307
+
308
+ ## Dynamic lookup message contract (Node-RED source)
309
+
310
+ Portal request (uibuilder output #1):
311
+
312
+ ```json
313
+ { "payload": { "type": "lookup:get", "lookupId": "ips", "matchValue": "192.168.1.0/24" } }
314
+ ```
315
+
316
+ Node-RED response (uibuilder input):
317
+
318
+ ```json
319
+ {
320
+ "payload": {
321
+ "type": "lookup:data",
322
+ "lookupId": "ips",
323
+ "matchValue": "192.168.1.0/24",
324
+ "items": [
325
+ { "ip": "192.168.1.0/24", "ips": ["192.168.1.0","192.168.1.1"] }
326
+ ]
327
+ }
328
+ }
329
+ ```
330
+
331
+ Notes:
332
+ - `items` must be an **array** of records (even if returning a single record).
333
+ - For textarea targets, `ips` can be an **array of strings** (recommended). Arrays of `{value,label}` are also accepted and will be joined into lines.
334
+
335
+ ## Submit + results contract (uibuilder mode)
336
+
337
+ Portal submits:
338
+
339
+ ```json
340
+ { "payload": { "type": "submit", "formId": "your_form_id", "payload": { "fieldA": "value" }, "meta": { "timestamp": "...", "userAgent": "...", "url": "..." } } }
341
+ ```
342
+
343
+ Return to show a result page:
344
+
345
+ ```json
346
+ { "payload": { "type": "submit:ok", "message": "OK", "result": [ { "colA": 1 }, { "colA": 2 } ] } }
347
+ ```
348
+
349
+ Or return an error:
350
+
351
+ ```json
352
+ { "payload": { "type": "submit:error", "message": "Failed", "error": { "details": "..." } } }
353
+ ```
300
354
  </script>
301
355
 
302
356
 
@@ -284,11 +284,11 @@ PortalSmith supports “smart fields” that auto-populate other fields based on
284
284
  When a field changes (e.g., OS selection), the portal sends to Node-RED (uibuilder output #1):
285
285
 
286
286
  ```json
287
- { "payload": { "type": "lookup:get", "lookupId": "osProfiles", "matchValue": "win11" } }
287
+ { "payload": { "type": "lookup:get", "lookupId": "profiles", "matchValue": "profileA" } }
288
288
  ```
289
289
 
290
290
  - `lookupId`: identifies which lookup list/service you want (you choose this string)
291
- - `matchValue`: the current selected value from the source field (e.g., `win11`)
291
+ - `matchValue`: the current selected value from the source field (e.g., `profileA`)
292
292
 
293
293
  Your flow must respond back to the same uibuilder instance (uibuilder input) with:
294
294
 
@@ -296,9 +296,9 @@ Your flow must respond back to the same uibuilder instance (uibuilder input) wit
296
296
  {
297
297
  "payload": {
298
298
  "type": "lookup:data",
299
- "lookupId": "osProfiles",
300
- "matchValue": "win11",
301
- "items": [ { "value": "win11", "label": "Windows 11", "devices": ["..."], "software": ["..."] } ]
299
+ "lookupId": "profiles",
300
+ "matchValue": "profileA",
301
+ "items": [ { "value": "profileA", "label": "Profile A", "devices": ["..."], "software": ["..."] } ]
302
302
  }
303
303
  }
304
304
  ```
@@ -307,6 +307,42 @@ Notes:
307
307
  - `items` must be an **array**. Each item should contain the properties referenced by your field’s mappings.
308
308
  - Include the same `matchValue` so the portal can cache responses per selection.
309
309
 
310
+ ## Cookbook: “if schema looks like this → return payload like that”
311
+
312
+ **Always** return `payload.items` as an **array** of records. Even if you only have one record, do `items: [ { ... } ]`.
313
+
314
+ ### Example: Subnet/CIDR → fill a textarea with an IP list
315
+
316
+ Schema mapping (on the field that has `autoFill`):
317
+
318
+ ```json
319
+ {
320
+ "fromField": "subnetAddress",
321
+ "source": { "type": "uibuilder", "lookupId": "ips" },
322
+ "matchKey": "ip",
323
+ "mappings": [ { "targetField": "iplist", "valueKey": "ips" } ]
324
+ }
325
+ ```
326
+
327
+ Node-RED response:
328
+
329
+ ```json
330
+ {
331
+ "payload": {
332
+ "type": "lookup:data",
333
+ "lookupId": "ips",
334
+ "matchValue": "192.168.1.0/24",
335
+ "items": [
336
+ { "ip": "192.168.1.0/24", "ips": ["192.168.1.0","192.168.1.1"] }
337
+ ]
338
+ }
339
+ }
340
+ ```
341
+
342
+ Notes:
343
+ - For textarea targets, `ips` can be an **array of strings** (recommended). Arrays of `{value,label}` are also accepted and will be joined into lines.
344
+ - If `items` is sent as a single object (not an array), matching will fail for `matchKey` lookups.
345
+
310
346
  ## Typical wiring
311
347
 
312
348
  - uibuilder output #1 → Switch (`msg.payload.type == lookup:get`) → Function → uibuilder input
@@ -316,7 +352,7 @@ Example Function node skeleton:
316
352
  ```js
317
353
  const p = msg.payload || {};
318
354
  if (p.type !== "lookup:get") return null;
319
- if (p.lookupId !== "osProfiles") return null;
355
+ if (p.lookupId !== "profiles") return null;
320
356
 
321
357
  msg.payload = {
322
358
  type: "lookup:data",
@@ -326,6 +362,26 @@ msg.payload = {
326
362
  };
327
363
  return msg;
328
364
  ```
365
+
366
+ ## Submit + results contract (uibuilder mode)
367
+
368
+ Portal submits:
369
+
370
+ ```json
371
+ { "payload": { "type": "submit", "formId": "your_form_id", "payload": { "fieldA": "value" }, "meta": { "timestamp": "...", "userAgent": "...", "url": "..." } } }
372
+ ```
373
+
374
+ Return to show a result page:
375
+
376
+ ```json
377
+ { "payload": { "type": "submit:ok", "message": "OK", "result": [ { "colA": 1 }, { "colA": 2 } ] } }
378
+ ```
379
+
380
+ Or return an error:
381
+
382
+ ```json
383
+ { "payload": { "type": "submit:error", "message": "Failed", "error": { "details": "..." } } }
384
+ ```
329
385
  </script>
330
386
 
331
387
 
@@ -528,7 +528,7 @@ module.exports = function(RED) {
528
528
  "src/form.schema.json": { sha256: await sha256FileHex(targetSchema) },
529
529
  };
530
530
  const runtimeData = {
531
- generatorVersion: "0.5.25",
531
+ generatorVersion: "0.5.27",
532
532
  generatorNode: "uibuilder-formgen-v3",
533
533
  timestamp: new Date().toISOString(),
534
534
  instanceName: instanceName,
@@ -311,7 +311,7 @@ PortalSmith supports “smart fields” that auto-populate other fields based on
311
311
  When a field changes (e.g., OS selection), the portal sends to Node-RED (uibuilder output #1):
312
312
 
313
313
  ```json
314
- { "payload": { "type": "lookup:get", "lookupId": "osProfiles", "matchValue": "win11" } }
314
+ { "payload": { "type": "lookup:get", "lookupId": "profiles", "matchValue": "profileA" } }
315
315
  ```
316
316
 
317
317
  Your flow must respond back to the same uibuilder instance (uibuilder input) with:
@@ -320,14 +320,70 @@ Your flow must respond back to the same uibuilder instance (uibuilder input) wit
320
320
  {
321
321
  "payload": {
322
322
  "type": "lookup:data",
323
- "lookupId": "osProfiles",
324
- "matchValue": "win11",
325
- "items": [ { "value": "win11", "label": "Windows 11", "devices": ["..."], "software": ["..."] } ]
323
+ "lookupId": "profiles",
324
+ "matchValue": "profileA",
325
+ "items": [ { "value": "profileA", "label": "Profile A", "devices": ["..."], "software": ["..."] } ]
326
326
  }
327
327
  }
328
328
  ```
329
329
 
330
+ ## Cookbook: “if schema looks like this → return payload like that”
331
+
332
+ **Always** return `payload.items` as an **array** of records. Even if you only have one record, do `items: [ { ... } ]`.
333
+
334
+ ### Example: Subnet/CIDR → fill a textarea with an IP list
335
+
336
+ Schema mapping (on the field that has `autoFill`):
337
+
338
+ ```json
339
+ {
340
+ "fromField": "subnetAddress",
341
+ "source": { "type": "uibuilder", "lookupId": "ips" },
342
+ "matchKey": "ip",
343
+ "mappings": [ { "targetField": "iplist", "valueKey": "ips" } ]
344
+ }
345
+ ```
346
+
347
+ Node-RED response:
348
+
349
+ ```json
350
+ {
351
+ "payload": {
352
+ "type": "lookup:data",
353
+ "lookupId": "ips",
354
+ "matchValue": "192.168.1.0/24",
355
+ "items": [
356
+ { "ip": "192.168.1.0/24", "ips": ["192.168.1.0","192.168.1.1"] }
357
+ ]
358
+ }
359
+ }
360
+ ```
361
+
362
+ Notes:
363
+ - For textarea targets, `ips` can be an **array of strings** (recommended). Arrays of `{value,label}` are also accepted and will be joined into lines.
364
+ - If `items` is sent as a single object (not an array), matching will fail for `matchKey` lookups.
365
+
330
366
  ## Typical wiring
331
367
 
332
368
  - uibuilder output #1 → Switch (`msg.payload.type == lookup:get`) → Function → uibuilder input
369
+
370
+ ## Submit + results contract (uibuilder mode)
371
+
372
+ Portal submits:
373
+
374
+ ```json
375
+ { "payload": { "type": "submit", "formId": "your_form_id", "payload": { "fieldA": "value" }, "meta": { "timestamp": "...", "userAgent": "...", "url": "..." } } }
376
+ ```
377
+
378
+ Return to show a result page:
379
+
380
+ ```json
381
+ { "payload": { "type": "submit:ok", "message": "OK", "result": [ { "colA": 1 }, { "colA": 2 } ] } }
382
+ ```
383
+
384
+ Or return an error:
385
+
386
+ ```json
387
+ { "payload": { "type": "submit:error", "message": "Failed", "error": { "details": "..." } } }
388
+ ```
333
389
  </script>
@@ -560,7 +560,7 @@ module.exports = function(RED) {
560
560
  "src/form.schema.json": { sha256: await sha256FileHex(targetSchema) },
561
561
  };
562
562
  const runtimeData = {
563
- generatorVersion: "0.5.25",
563
+ generatorVersion: "0.5.27",
564
564
  timestamp: new Date().toISOString(),
565
565
  instanceName: instanceName,
566
566
  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.25",
3
+ "version": "0.5.27",
4
4
  "description": "PortalSmith: Generate schema-driven uibuilder form portals from JSON",
5
5
  "keywords": [
6
6
  "node-red",
@@ -181,9 +181,24 @@
181
181
  border-color: rgba(255,255,255,0.14);
182
182
  }
183
183
  .form-container {
184
- max-width: 800px;
184
+ /* Mobile-first: keep layout fluid; constrain only on larger screens */
185
+ max-width: 100%;
185
186
  margin: 0 auto;
186
187
  }
188
+ @media (min-width: 1200px) {
189
+ .form-container { max-width: 1400px; }
190
+ }
191
+ @media (min-width: 1600px) {
192
+ .form-container { max-width: 1600px; }
193
+ }
194
+
195
+ /* Results tables: avoid wrapping on desktop; allow horizontal scroll instead */
196
+ @media (min-width: 992px) {
197
+ .table-responsive table td,
198
+ .table-responsive table th {
199
+ white-space: nowrap !important;
200
+ }
201
+ }
187
202
  .field-error {
188
203
  color: #dc3545;
189
204
  font-size: 0.875rem;
@@ -281,6 +296,33 @@
281
296
  </button>
282
297
  </div>
283
298
 
299
+ <div v-if="resultIsArrayOfObjects && resultView !== 'form'" class="d-flex flex-wrap align-items-center justify-content-between mb-2">
300
+ <div class="d-flex align-items-center flex-wrap">
301
+ <span class="text-muted small mr-2">Search</span>
302
+ <input
303
+ type="text"
304
+ class="form-control form-control-sm mr-2"
305
+ style="width:260px;max-width:100%;"
306
+ v-model="resultSearch"
307
+ placeholder="Search any column..."
308
+ >
309
+ <button type="button" class="btn btn-sm btn-outline-secondary mr-2" @click="clearResultSearch" :disabled="!resultSearch">
310
+ Clear
311
+ </button>
312
+ <span class="text-muted small" v-if="resultSearch">
313
+ {{ resultFilteredCount }} match<span v-if="resultFilteredCount !== 1">es</span>
314
+ </span>
315
+ </div>
316
+ <div class="btn-group btn-group-sm mt-2 mt-md-0" role="group" aria-label="Export filtered">
317
+ <button type="button" class="btn btn-outline-primary" @click="downloadFilteredResultJson" :disabled="resultFilteredCount === 0">
318
+ Export Filtered JSON
319
+ </button>
320
+ <button type="button" class="btn btn-outline-primary" @click="downloadFilteredResultCsv" :disabled="resultFilteredCount === 0">
321
+ Export Filtered CSV
322
+ </button>
323
+ </div>
324
+ </div>
325
+
284
326
  <div v-if="showResultPagination && resultView !== 'form'" class="d-flex flex-wrap align-items-center justify-content-between mb-2">
285
327
  <div class="d-flex align-items-center">
286
328
  <span class="text-muted small mr-2">Rows per page</span>
@@ -306,7 +348,17 @@
306
348
  <table class="table table-sm table-bordered mb-0">
307
349
  <thead>
308
350
  <tr>
309
- <th v-for="h in resultRecordHeaders" :key="h">{{h}}</th>
351
+ <th
352
+ v-for="h in resultRecordHeaders"
353
+ :key="h"
354
+ @click="toggleResultSort(h)"
355
+ style="cursor:pointer;user-select:none;"
356
+ >
357
+ {{h}}
358
+ <span v-if="resultSortKey === h" class="text-muted">
359
+ {{ resultSortDir === 'asc' ? '▲' : '▼' }}
360
+ </span>
361
+ </th>
310
362
  </tr>
311
363
  </thead>
312
364
  <tbody>
@@ -761,6 +761,9 @@
761
761
  resultPageSize: 15,
762
762
  resultPageSizeOptions: [10, 15, 25, 50, 100, 1000],
763
763
  resultRecordIndex: 0,
764
+ resultSearch: '',
765
+ resultSortKey: '',
766
+ resultSortDir: 'asc',
764
767
  copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
765
768
  exportFormats: CONFIG.exportFormats
766
769
  },
@@ -805,6 +808,40 @@
805
808
  return out;
806
809
  });
807
810
  },
811
+ resultFilteredRecordRows() {
812
+ if (!this.resultIsArrayOfObjects) return [];
813
+ const q = String(this.resultSearch || '').trim().toLowerCase();
814
+ const rows = this.resultRecordRows || [];
815
+ if (!q) return rows;
816
+ const headers = this.resultRecordHeaders || [];
817
+ return rows.filter(r => {
818
+ const hay = headers.map(h => (r && r[h] !== undefined && r[h] !== null) ? String(r[h]) : '').join(' ').toLowerCase();
819
+ return hay.includes(q);
820
+ });
821
+ },
822
+ resultSortedRecordRows() {
823
+ if (!this.resultIsArrayOfObjects) return [];
824
+ const rows = (this.resultFilteredRecordRows || []).slice();
825
+ const key = String(this.resultSortKey || '').trim();
826
+ if (!key) return rows;
827
+ const dir = (String(this.resultSortDir || 'asc').toLowerCase() === 'desc') ? -1 : 1;
828
+ const coll = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
829
+ rows.sort((a, b) => {
830
+ const av = (a && a[key] !== undefined && a[key] !== null) ? a[key] : '';
831
+ const bv = (b && b[key] !== undefined && b[key] !== null) ? b[key] : '';
832
+ // Try numeric compare when both look numeric
833
+ const an = Number(av);
834
+ const bn = Number(bv);
835
+ const aNum = (String(av).trim() !== '' && Number.isFinite(an));
836
+ const bNum = (String(bv).trim() !== '' && Number.isFinite(bn));
837
+ if (aNum && bNum) return (an === bn ? 0 : (an < bn ? -1 : 1)) * dir;
838
+ return coll.compare(String(av), String(bv)) * dir;
839
+ });
840
+ return rows;
841
+ },
842
+ resultFilteredCount() {
843
+ return this.resultIsArrayOfObjects ? (this.resultSortedRecordRows || []).length : 0;
844
+ },
808
845
  resultKeyValueRows() {
809
846
  const r = this.lastResult;
810
847
  if (r === null || r === undefined) return [];
@@ -829,7 +866,7 @@
829
866
  }];
830
867
  },
831
868
  resultTotalRows() {
832
- return this.resultIsArrayOfObjects ? (this.resultRecordRows || []).length : (this.resultKeyValueRows || []).length;
869
+ return this.resultIsArrayOfObjects ? (this.resultSortedRecordRows || []).length : (this.resultKeyValueRows || []).length;
833
870
  },
834
871
  showResultPagination() {
835
872
  return this.resultTotalRows > 10;
@@ -847,11 +884,24 @@
847
884
  return this.resultPageStart + sz;
848
885
  },
849
886
  resultPagedRows() {
850
- const rows = this.resultIsArrayOfObjects ? (this.resultRecordRows || []) : (this.resultKeyValueRows || []);
887
+ const rows = this.resultIsArrayOfObjects ? (this.resultSortedRecordRows || []) : (this.resultKeyValueRows || []);
851
888
  if (!this.showResultPagination) return rows;
852
889
  return rows.slice(this.resultPageStart, this.resultPageEnd);
853
890
  },
854
891
  jsonResultText() {
892
+ // If we have an array-of-objects and search/sort is active, reflect that in JSON view too.
893
+ if (this.resultIsArrayOfObjects) {
894
+ const all = (this.resultSortedRecordRows || []).map(r => {
895
+ const out = {};
896
+ (this.resultRecordHeaders || []).forEach(h => { out[h] = r[h]; });
897
+ return out;
898
+ });
899
+ if (all.length > 10) {
900
+ const slice = all.slice(this.resultPageStart, this.resultPageEnd);
901
+ try { return JSON.stringify(slice, null, 2); } catch (e) { return String(slice); }
902
+ }
903
+ try { return JSON.stringify(all, null, 2); } catch (e) { return String(all); }
904
+ }
855
905
  if (Array.isArray(this.lastResult) && this.lastResult.length > 10) {
856
906
  const slice = this.lastResult.slice(this.resultPageStart, this.resultPageEnd);
857
907
  try { return JSON.stringify(slice, null, 2); } catch (e) { return String(slice); }
@@ -890,8 +940,9 @@
890
940
  },
891
941
  watch: {
892
942
  // Reset paging and record navigation whenever result changes
893
- lastResult() { this.resultPage = 1; this.resultRecordIndex = 0; },
943
+ lastResult() { this.resultPage = 1; this.resultRecordIndex = 0; this.resultSearch = ''; this.resultSortKey = ''; this.resultSortDir = 'asc'; },
894
944
  resultPageSize() { this.resultPage = 1; },
945
+ resultSearch() { this.resultPage = 1; },
895
946
  resultView(val) {
896
947
  if (val === 'form') {
897
948
  // Clamp/reset index for form mode
@@ -901,6 +952,41 @@
901
952
  }
902
953
  },
903
954
  methods: {
955
+ toggleResultSort(key) {
956
+ const k = String(key || '').trim();
957
+ if (!k) return;
958
+ if (this.resultSortKey === k) {
959
+ this.resultSortDir = (this.resultSortDir === 'asc') ? 'desc' : 'asc';
960
+ } else {
961
+ this.resultSortKey = k;
962
+ this.resultSortDir = 'asc';
963
+ }
964
+ this.resultPage = 1;
965
+ },
966
+ clearResultSearch() {
967
+ this.resultSearch = '';
968
+ this.resultPage = 1;
969
+ },
970
+ downloadFilteredResultJson() {
971
+ if (!this.resultIsArrayOfObjects) return;
972
+ const headers = this.resultRecordHeaders || [];
973
+ const rows = (this.resultSortedRecordRows || []).map(r => {
974
+ const out = {};
975
+ headers.forEach(h => { out[h] = r[h]; });
976
+ return out;
977
+ });
978
+ const filename = `${CONFIG.formId || 'form'}-result-filtered-${timestampForFilename()}.json`;
979
+ downloadTextFile(filename, 'application/json;charset=utf-8', JSON.stringify(rows, null, 2));
980
+ },
981
+ downloadFilteredResultCsv() {
982
+ if (!this.resultIsArrayOfObjects) return;
983
+ const headers = this.resultRecordHeaders || [];
984
+ const rows = this.resultSortedRecordRows || [];
985
+ const headerLine = headers.map(escapeCsvCell).join(',');
986
+ const body = rows.map(r => headers.map(h => escapeCsvCell(r && r[h] !== undefined ? r[h] : '')).join(',')).join('\n');
987
+ const filename = `${CONFIG.formId || 'form'}-result-filtered-${timestampForFilename()}.csv`;
988
+ downloadTextFile(filename, 'text/csv;charset=utf-8', headerLine + '\n' + body + '\n');
989
+ },
904
990
  flushAutofill(fieldId) {
905
991
  try {
906
992
  const id = String(fieldId || '').trim();
@@ -179,9 +179,24 @@
179
179
  border-color: rgba(255,255,255,0.14);
180
180
  }
181
181
  .form-container {
182
- max-width: 800px;
182
+ /* Mobile-first: keep layout fluid; constrain only on larger screens */
183
+ max-width: 100%;
183
184
  margin: 0 auto;
184
185
  }
186
+ @media (min-width: 1200px) {
187
+ .form-container { max-width: 1400px; }
188
+ }
189
+ @media (min-width: 1600px) {
190
+ .form-container { max-width: 1600px; }
191
+ }
192
+
193
+ /* Results tables: avoid wrapping on desktop; allow horizontal scroll instead */
194
+ @media (min-width: 992px) {
195
+ .table-responsive table td,
196
+ .table-responsive table th {
197
+ white-space: nowrap !important;
198
+ }
199
+ }
185
200
  .field-error {
186
201
  color: #dc3545;
187
202
  font-size: 0.875rem;
@@ -279,6 +294,33 @@
279
294
  </button>
280
295
  </div>
281
296
 
297
+ <div v-if="resultIsArrayOfObjects && resultView !== 'form'" class="d-flex flex-wrap align-items-center justify-content-between mb-2">
298
+ <div class="d-flex align-items-center flex-wrap">
299
+ <span class="text-muted small mr-2">Search</span>
300
+ <input
301
+ type="text"
302
+ class="form-control form-control-sm mr-2"
303
+ style="width:260px;max-width:100%;"
304
+ v-model="resultSearch"
305
+ placeholder="Search any column..."
306
+ >
307
+ <button type="button" class="btn btn-sm btn-outline-secondary mr-2" @click="clearResultSearch" :disabled="!resultSearch">
308
+ Clear
309
+ </button>
310
+ <span class="text-muted small" v-if="resultSearch">
311
+ {{ resultFilteredCount }} match<span v-if="resultFilteredCount !== 1">es</span>
312
+ </span>
313
+ </div>
314
+ <div class="btn-group btn-group-sm mt-2 mt-md-0" role="group" aria-label="Export filtered">
315
+ <button type="button" class="btn btn-outline-primary" @click="downloadFilteredResultJson" :disabled="resultFilteredCount === 0">
316
+ Export Filtered JSON
317
+ </button>
318
+ <button type="button" class="btn btn-outline-primary" @click="downloadFilteredResultCsv" :disabled="resultFilteredCount === 0">
319
+ Export Filtered CSV
320
+ </button>
321
+ </div>
322
+ </div>
323
+
282
324
  <div v-if="showResultPagination && resultView !== 'form'" class="d-flex flex-wrap align-items-center justify-content-between mb-2">
283
325
  <div class="d-flex align-items-center">
284
326
  <span class="text-muted small mr-2">Rows per page</span>
@@ -304,7 +346,17 @@
304
346
  <table class="table table-sm table-bordered mb-0">
305
347
  <thead>
306
348
  <tr>
307
- <th v-for="h in resultRecordHeaders" :key="h">{{h}}</th>
349
+ <th
350
+ v-for="h in resultRecordHeaders"
351
+ :key="h"
352
+ @click="toggleResultSort(h)"
353
+ style="cursor:pointer;user-select:none;"
354
+ >
355
+ {{h}}
356
+ <span v-if="resultSortKey === h" class="text-muted">
357
+ {{ resultSortDir === 'asc' ? '▲' : '▼' }}
358
+ </span>
359
+ </th>
308
360
  </tr>
309
361
  </thead>
310
362
  <tbody>
@@ -164,9 +164,24 @@
164
164
  border-color: rgba(255,255,255,0.14);
165
165
  }
166
166
  .form-container {
167
- max-width: 800px;
167
+ /* Mobile-first: keep layout fluid; constrain only on larger screens */
168
+ max-width: 100%;
168
169
  margin: 0 auto;
169
170
  }
171
+ @media (min-width: 1200px) {
172
+ .form-container { max-width: 1400px; }
173
+ }
174
+ @media (min-width: 1600px) {
175
+ .form-container { max-width: 1600px; }
176
+ }
177
+
178
+ /* Results tables: avoid wrapping on desktop; allow horizontal scroll instead */
179
+ @media (min-width: 992px) {
180
+ .table-responsive table td,
181
+ .table-responsive table th {
182
+ white-space: nowrap !important;
183
+ }
184
+ }
170
185
  .field-error {
171
186
  color: #dc3545;
172
187
  font-size: 0.875rem;
@@ -256,6 +271,33 @@
256
271
  <button type="button" class="btn btn-sm" :class="resultView === 'form' ? 'btn-primary' : 'btn-outline-primary'" @click="resultView = 'form'">Form</button>
257
272
  </div>
258
273
 
274
+ <div v-if="(formattedResult && formattedResult.length) && resultView !== 'form'" class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
275
+ <div class="d-flex align-items-center flex-wrap gap-2">
276
+ <span class="text-muted small">Search</span>
277
+ <input
278
+ type="text"
279
+ class="form-control form-control-sm"
280
+ style="width:260px;max-width:100%;"
281
+ v-model="resultSearch"
282
+ placeholder="Search any column..."
283
+ >
284
+ <button type="button" class="btn btn-sm btn-outline-secondary" @click="clearResultSearch" :disabled="!resultSearch">
285
+ Clear
286
+ </button>
287
+ <span class="text-muted small" v-if="resultSearch">
288
+ {{ filteredCount }} match<span v-if="filteredCount !== 1">es</span>
289
+ </span>
290
+ </div>
291
+ <div class="btn-group btn-group-sm" role="group" aria-label="Export filtered">
292
+ <button type="button" class="btn btn-outline-primary" @click="downloadFilteredResultJson" :disabled="filteredCount === 0">
293
+ Export Filtered JSON
294
+ </button>
295
+ <button type="button" class="btn btn-outline-primary" @click="downloadFilteredResultCsv" :disabled="filteredCount === 0">
296
+ Export Filtered CSV
297
+ </button>
298
+ </div>
299
+ </div>
300
+
259
301
  <div v-if="showResultPagination && resultView !== 'form'" class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
260
302
  <div class="d-flex align-items-center gap-2">
261
303
  <span class="text-muted small">Rows per page</span>
@@ -277,7 +319,17 @@
277
319
  <table class="table table-bordered table-sm">
278
320
  <thead>
279
321
  <tr>
280
- <th v-for="h in resultHeaders" :key="h">{{h}}</th>
322
+ <th
323
+ v-for="h in resultHeaders"
324
+ :key="h"
325
+ @click="toggleResultSort(h)"
326
+ style="cursor:pointer;user-select:none;"
327
+ >
328
+ {{h}}
329
+ <span v-if="resultSortKey === h" class="text-muted">
330
+ {{ resultSortDir === 'asc' ? '▲' : '▼' }}
331
+ </span>
332
+ </th>
281
333
  </tr>
282
334
  </thead>
283
335
  <tbody>
@@ -512,6 +512,9 @@
512
512
  resultPageSize: 15,
513
513
  resultPageSizeOptions: [10, 15, 25, 50, 100, 1000],
514
514
  resultRecordIndex: 0,
515
+ resultSearch: '',
516
+ resultSortKey: '',
517
+ resultSortDir: 'asc',
515
518
  copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
516
519
  exportFormats: CONFIG.exportFormats
517
520
  };
@@ -549,8 +552,39 @@
549
552
  // Single object
550
553
  return [this.flattenRow(r)];
551
554
  },
555
+ filteredResult() {
556
+ const rows = this.formattedResult || [];
557
+ const q = String(this.resultSearch || '').trim().toLowerCase();
558
+ if (!q) return rows;
559
+ const headers = this.resultHeaders || [];
560
+ return rows.filter(r => {
561
+ const hay = headers.map(h => (r && r[h] !== undefined && r[h] !== null) ? String(r[h]) : '').join(' ').toLowerCase();
562
+ return hay.includes(q);
563
+ });
564
+ },
565
+ sortedFilteredResult() {
566
+ const rows = (this.filteredResult || []).slice();
567
+ const key = String(this.resultSortKey || '').trim();
568
+ if (!key) return rows;
569
+ const dir = (String(this.resultSortDir || 'asc').toLowerCase() === 'desc') ? -1 : 1;
570
+ const coll = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
571
+ rows.sort((a, b) => {
572
+ const av = (a && a[key] !== undefined && a[key] !== null) ? a[key] : '';
573
+ const bv = (b && b[key] !== undefined && b[key] !== null) ? b[key] : '';
574
+ const an = Number(av);
575
+ const bn = Number(bv);
576
+ const aNum = (String(av).trim() !== '' && Number.isFinite(an));
577
+ const bNum = (String(bv).trim() !== '' && Number.isFinite(bn));
578
+ if (aNum && bNum) return (an === bn ? 0 : (an < bn ? -1 : 1)) * dir;
579
+ return coll.compare(String(av), String(bv)) * dir;
580
+ });
581
+ return rows;
582
+ },
583
+ filteredCount() {
584
+ return (this.sortedFilteredResult || []).length;
585
+ },
552
586
  resultTotalRows() {
553
- return (this.formattedResult || []).length;
587
+ return (this.sortedFilteredResult || []).length;
554
588
  },
555
589
  showResultPagination() {
556
590
  // Show pager when records exceed 10
@@ -569,7 +603,7 @@
569
603
  return this.resultPageStart + sz;
570
604
  },
571
605
  pagedFormattedResult() {
572
- const rows = this.formattedResult || [];
606
+ const rows = this.sortedFilteredResult || [];
573
607
  if (!this.showResultPagination) return rows;
574
608
  return rows.slice(this.resultPageStart, this.resultPageEnd);
575
609
  },
@@ -612,8 +646,9 @@
612
646
  }
613
647
  },
614
648
  watch: {
615
- lastResult() { this.resultPage = 1; this.resultRecordIndex = 0; },
649
+ lastResult() { this.resultPage = 1; this.resultRecordIndex = 0; this.resultSearch = ''; this.resultSortKey = ''; this.resultSortDir = 'asc'; },
616
650
  resultPageSize() { this.resultPage = 1; },
651
+ resultSearch() { this.resultPage = 1; },
617
652
  resultView(val) {
618
653
  if (val === 'form') {
619
654
  const max = Math.max(0, (this.resultRecordCount || 1) - 1);
@@ -622,6 +657,34 @@
622
657
  }
623
658
  },
624
659
  methods: {
660
+ toggleResultSort(key) {
661
+ const k = String(key || '').trim();
662
+ if (!k) return;
663
+ if (this.resultSortKey === k) {
664
+ this.resultSortDir = (this.resultSortDir === 'asc') ? 'desc' : 'asc';
665
+ } else {
666
+ this.resultSortKey = k;
667
+ this.resultSortDir = 'asc';
668
+ }
669
+ this.resultPage = 1;
670
+ },
671
+ clearResultSearch() {
672
+ this.resultSearch = '';
673
+ this.resultPage = 1;
674
+ },
675
+ downloadFilteredResultJson() {
676
+ const rows = this.sortedFilteredResult || [];
677
+ const ts = timestampForFilename();
678
+ downloadTextFile(`${CONFIG.formId}_result_filtered_${ts}.json`, 'application/json', JSON.stringify(rows, null, 2));
679
+ },
680
+ downloadFilteredResultCsv() {
681
+ const rows = this.sortedFilteredResult || [];
682
+ const headers = this.resultHeaders || [];
683
+ const headerLine = headers.map(escapeCsvCell).join(',');
684
+ const body = rows.map(r => headers.map(h => escapeCsvCell(r && r[h] !== undefined ? r[h] : '')).join(',')).join('\n');
685
+ const ts = timestampForFilename();
686
+ downloadTextFile(`${CONFIG.formId}_result_filtered_${ts}.csv`, 'text/csv', headerLine + '\n' + body + '\n');
687
+ },
625
688
  flushAutofill(fieldId) {
626
689
  try {
627
690
  const id = String(fieldId || '').trim();