@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 +8 -0
- package/docs/user-guide.html +175 -17
- package/nodes/uibuilder-formgen-uib2.html +54 -0
- package/nodes/uibuilder-formgen-v3.html +62 -6
- package/nodes/uibuilder-formgen-v3.js +1 -1
- package/nodes/uibuilder-formgen.html +60 -4
- package/nodes/uibuilder-formgen.js +1 -1
- package/package.json +1 -1
- package/templates/index.html.mustache +54 -2
- package/templates/index.js.mustache +89 -3
- package/templates/index.uib2.html.mustache +54 -2
- package/templates/index.v3.html.mustache +54 -2
- package/templates/index.v3.js.mustache +66 -3
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.
|
package/docs/user-guide.html
CHANGED
|
@@ -516,7 +516,7 @@
|
|
|
516
516
|
<pre>{
|
|
517
517
|
"lookups": {
|
|
518
518
|
"deviceTemplates": [
|
|
519
|
-
{ "name": "
|
|
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
|
-
"
|
|
554
|
+
"profiles": [
|
|
555
555
|
{
|
|
556
|
-
"value": "
|
|
557
|
-
"label": "
|
|
558
|
-
"devices": ["
|
|
559
|
-
"software": ["
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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 === "
|
|
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: "
|
|
656
|
-
{ name: "
|
|
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>
|
|
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": ["
|
|
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
|
|
809
|
-
<li><strong>
|
|
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": "
|
|
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., `
|
|
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": "
|
|
300
|
-
"matchValue": "
|
|
301
|
-
"items": [ { "value": "
|
|
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 !== "
|
|
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.
|
|
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": "
|
|
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": "
|
|
324
|
-
"matchValue": "
|
|
325
|
-
"items": [ { "value": "
|
|
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.
|
|
563
|
+
generatorVersion: "0.5.27",
|
|
564
564
|
timestamp: new Date().toISOString(),
|
|
565
565
|
instanceName: instanceName,
|
|
566
566
|
storageMode: storageMode,
|
package/package.json
CHANGED
|
@@ -181,9 +181,24 @@
|
|
|
181
181
|
border-color: rgba(255,255,255,0.14);
|
|
182
182
|
}
|
|
183
183
|
.form-container {
|
|
184
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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();
|