@cyprnet/node-red-contrib-uibuilder-formgen 0.4.11
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 +33 -0
- package/LICENSE +22 -0
- package/README.md +58 -0
- package/docs/user-guide.html +565 -0
- package/examples/formgen-builder/src/index.html +921 -0
- package/examples/formgen-builder/src/index.js +1338 -0
- package/examples/portalsmith-formgen-example.json +531 -0
- package/examples/schema-builder-integration.json +109 -0
- package/examples/schemas/Banking/banking_fraud_report.json +102 -0
- package/examples/schemas/Banking/banking_kyc_update.json +59 -0
- package/examples/schemas/Banking/banking_loan_application.json +113 -0
- package/examples/schemas/Banking/banking_new_account.json +98 -0
- package/examples/schemas/Banking/banking_wire_transfer_request.json +94 -0
- package/examples/schemas/HR/hr_employee_change_form.json +65 -0
- package/examples/schemas/HR/hr_exit_interview.json +105 -0
- package/examples/schemas/HR/hr_job_application.json +166 -0
- package/examples/schemas/HR/hr_onboarding_request.json +140 -0
- package/examples/schemas/HR/hr_time_off_request.json +95 -0
- package/examples/schemas/HR/hr_training_request.json +70 -0
- package/examples/schemas/Healthcare/health_appointment_request.json +103 -0
- package/examples/schemas/Healthcare/health_incident_report.json +82 -0
- package/examples/schemas/Healthcare/health_lab_order_request.json +72 -0
- package/examples/schemas/Healthcare/health_medication_refill.json +72 -0
- package/examples/schemas/Healthcare/health_patient_intake.json +113 -0
- package/examples/schemas/IT/it_access_request.json +145 -0
- package/examples/schemas/IT/it_dhcp_reservation.json +175 -0
- package/examples/schemas/IT/it_dns_domain_external.json +192 -0
- package/examples/schemas/IT/it_dns_domain_internal.json +171 -0
- package/examples/schemas/IT/it_network_change_request.json +126 -0
- package/examples/schemas/IT/it_network_request-form.json +299 -0
- package/examples/schemas/IT/it_new_hardware_request.json +155 -0
- package/examples/schemas/IT/it_password_reset.json +133 -0
- package/examples/schemas/IT/it_software_license_request.json +93 -0
- package/examples/schemas/IT/it_static_ip_request.json +199 -0
- package/examples/schemas/IT/it_subnet_request_form.json +216 -0
- package/examples/schemas/Maintenance/maint_checklist.json +176 -0
- package/examples/schemas/Maintenance/maint_facility_issue_report.json +127 -0
- package/examples/schemas/Maintenance/maint_incident_intake.json +174 -0
- package/examples/schemas/Maintenance/maint_inventory_restock.json +79 -0
- package/examples/schemas/Maintenance/maint_safety_audit.json +92 -0
- package/examples/schemas/Maintenance/maint_vehicle_inspection.json +112 -0
- package/examples/schemas/Maintenance/maint_work_order.json +134 -0
- package/index.js +12 -0
- package/lib/licensing.js +254 -0
- package/nodes/portalsmith-license.html +40 -0
- package/nodes/portalsmith-license.js +23 -0
- package/nodes/uibuilder-formgen.html +261 -0
- package/nodes/uibuilder-formgen.js +598 -0
- package/package.json +47 -0
- package/scripts/normalize_schema_titles.py +77 -0
- package/templates/index.html.mustache +541 -0
- package/templates/index.js.mustache +1135 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Normalize example schema titles to ASCII (remove “UTF-8 fancy” punctuation).
|
|
4
|
+
|
|
5
|
+
Currently replaces common unicode punctuation (e.g., em-dash "—") with ASCII.
|
|
6
|
+
It then strips any remaining non-ASCII characters from the title.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 scripts/normalize_schema_titles.py
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
REPLACEMENTS = {
|
|
20
|
+
"\u2014": "-", # em dash —
|
|
21
|
+
"\u2013": "-", # en dash –
|
|
22
|
+
"\u2212": "-", # minus sign −
|
|
23
|
+
"\u00a0": " ", # non-breaking space
|
|
24
|
+
"\u2018": "'", # left single quote
|
|
25
|
+
"\u2019": "'", # right single quote
|
|
26
|
+
"\u201c": '"', # left double quote
|
|
27
|
+
"\u201d": '"', # right double quote
|
|
28
|
+
"\u2026": "...", # ellipsis
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_title(title: str) -> str:
|
|
33
|
+
out = title
|
|
34
|
+
for k, v in REPLACEMENTS.items():
|
|
35
|
+
out = out.replace(k, v)
|
|
36
|
+
# Strip any remaining non-ascii characters
|
|
37
|
+
out = out.encode("ascii", "ignore").decode("ascii")
|
|
38
|
+
# Collapse double spaces created by removals
|
|
39
|
+
out = " ".join(out.split())
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main() -> int:
|
|
44
|
+
repo_root = Path(__file__).resolve().parents[1]
|
|
45
|
+
schemas_root = repo_root / "examples" / "schemas"
|
|
46
|
+
if not schemas_root.exists():
|
|
47
|
+
print(f"ERROR: schemas folder not found: {schemas_root}")
|
|
48
|
+
return 2
|
|
49
|
+
|
|
50
|
+
changed = 0
|
|
51
|
+
scanned = 0
|
|
52
|
+
for path in schemas_root.rglob("*.json"):
|
|
53
|
+
scanned += 1
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"SKIP (invalid json): {path}: {e}")
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
title = data.get("title")
|
|
61
|
+
if not isinstance(title, str):
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
new_title = normalize_title(title)
|
|
65
|
+
if new_title != title:
|
|
66
|
+
data["title"] = new_title
|
|
67
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
|
|
68
|
+
changed += 1
|
|
69
|
+
|
|
70
|
+
print(f"Scanned {scanned} schema files. Updated titles in {changed} files.")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
raise SystemExit(main())
|
|
76
|
+
|
|
77
|
+
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="[[themeMode]]">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<!-- SPDX-License-Identifier: MIT -->
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
7
|
+
<title>[[title]]</title>
|
|
8
|
+
<meta name="color-scheme" content="light dark">
|
|
9
|
+
|
|
10
|
+
<!-- Bootstrap 4 CSS -->
|
|
11
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css" integrity="sha384-xOolHFLEh07PJGoPkLv1IbcEPTNtaed2xpHsD9ESMhqIYd0nLMwNLD69Npy4HI+N" crossorigin="anonymous">
|
|
12
|
+
|
|
13
|
+
<!-- Bootstrap-Vue CSS -->
|
|
14
|
+
<link rel="stylesheet" href="https://unpkg.com/bootstrap-vue@2.23.1/dist/bootstrap-vue.min.css">
|
|
15
|
+
|
|
16
|
+
<style>
|
|
17
|
+
/* PortalSmith theming
|
|
18
|
+
themeMode: [[themeMode]] ('auto' | 'light' | 'dark')
|
|
19
|
+
Uses CSS variables so we can skin Bootstrap 4 components without replacing Bootstrap.
|
|
20
|
+
*/
|
|
21
|
+
:root {
|
|
22
|
+
--ps-bg: #f5f5f5;
|
|
23
|
+
--ps-surface: #ffffff;
|
|
24
|
+
--ps-text: #212529;
|
|
25
|
+
--ps-muted: #6c757d;
|
|
26
|
+
--ps-border: #dee2e6;
|
|
27
|
+
--ps-input-bg: #ffffff;
|
|
28
|
+
--ps-input-text: #212529;
|
|
29
|
+
--ps-input-border: #ced4da;
|
|
30
|
+
--ps-code-bg: rgba(0,0,0,0.04);
|
|
31
|
+
--ps-link: #0d6efd;
|
|
32
|
+
--ps-focus: rgba(13,110,253,0.25);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
html[data-theme="dark"] {
|
|
36
|
+
--ps-bg: #0f1115;
|
|
37
|
+
--ps-surface: #171a21;
|
|
38
|
+
--ps-text: #e6eaf2;
|
|
39
|
+
--ps-muted: #aab3c2;
|
|
40
|
+
--ps-border: #2a2f3a;
|
|
41
|
+
--ps-input-bg: #11141a;
|
|
42
|
+
--ps-input-text: #e6eaf2;
|
|
43
|
+
--ps-input-border: #3a4150;
|
|
44
|
+
--ps-code-bg: rgba(255,255,255,0.08);
|
|
45
|
+
--ps-link: #8ab4ff;
|
|
46
|
+
--ps-focus: rgba(138,180,255,0.25);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* Auto mode: follow OS preference when theme isn't forced */
|
|
50
|
+
@media (prefers-color-scheme: dark) {
|
|
51
|
+
html:not([data-theme]),
|
|
52
|
+
html[data-theme="auto"] {
|
|
53
|
+
--ps-bg: #0f1115;
|
|
54
|
+
--ps-surface: #171a21;
|
|
55
|
+
--ps-text: #e6eaf2;
|
|
56
|
+
--ps-muted: #aab3c2;
|
|
57
|
+
--ps-border: #2a2f3a;
|
|
58
|
+
--ps-input-bg: #11141a;
|
|
59
|
+
--ps-input-text: #e6eaf2;
|
|
60
|
+
--ps-input-border: #3a4150;
|
|
61
|
+
--ps-code-bg: rgba(255,255,255,0.08);
|
|
62
|
+
--ps-link: #8ab4ff;
|
|
63
|
+
--ps-focus: rgba(138,180,255,0.25);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
body {
|
|
68
|
+
background-color: var(--ps-bg);
|
|
69
|
+
color: var(--ps-text);
|
|
70
|
+
padding: 20px 0;
|
|
71
|
+
}
|
|
72
|
+
a { color: var(--ps-link); }
|
|
73
|
+
.text-muted { color: var(--ps-muted) !important; }
|
|
74
|
+
.border { border-color: var(--ps-border) !important; }
|
|
75
|
+
|
|
76
|
+
/* Bootstrap components */
|
|
77
|
+
.card,
|
|
78
|
+
.modal-content,
|
|
79
|
+
.dropdown-menu,
|
|
80
|
+
.list-group-item {
|
|
81
|
+
background-color: var(--ps-surface);
|
|
82
|
+
color: var(--ps-text);
|
|
83
|
+
border-color: var(--ps-border);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/* Tables */
|
|
87
|
+
.table {
|
|
88
|
+
color: var(--ps-text);
|
|
89
|
+
}
|
|
90
|
+
.table th,
|
|
91
|
+
.table td {
|
|
92
|
+
color: var(--ps-text);
|
|
93
|
+
border-color: var(--ps-border) !important;
|
|
94
|
+
}
|
|
95
|
+
.table thead th {
|
|
96
|
+
background-color: var(--ps-surface);
|
|
97
|
+
color: var(--ps-text);
|
|
98
|
+
border-color: var(--ps-border) !important;
|
|
99
|
+
}
|
|
100
|
+
.table-bordered {
|
|
101
|
+
border-color: var(--ps-border) !important;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.form-control,
|
|
105
|
+
.custom-select {
|
|
106
|
+
background-color: var(--ps-input-bg);
|
|
107
|
+
color: var(--ps-input-text);
|
|
108
|
+
border-color: var(--ps-input-border);
|
|
109
|
+
}
|
|
110
|
+
.form-control:focus,
|
|
111
|
+
.custom-select:focus {
|
|
112
|
+
border-color: var(--ps-link);
|
|
113
|
+
box-shadow: 0 0 0 .2rem var(--ps-focus);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
pre, code {
|
|
117
|
+
color: var(--ps-text);
|
|
118
|
+
}
|
|
119
|
+
code {
|
|
120
|
+
background: var(--ps-code-bg);
|
|
121
|
+
padding: 0.1rem 0.25rem;
|
|
122
|
+
border-radius: 0.2rem;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* Header layout */
|
|
126
|
+
.ps-header {
|
|
127
|
+
display: flex;
|
|
128
|
+
justify-content: space-between;
|
|
129
|
+
align-items: center;
|
|
130
|
+
gap: 1rem;
|
|
131
|
+
margin-bottom: 1rem;
|
|
132
|
+
}
|
|
133
|
+
.ps-logo {
|
|
134
|
+
max-height: 48px;
|
|
135
|
+
max-width: 220px;
|
|
136
|
+
width: auto;
|
|
137
|
+
height: auto;
|
|
138
|
+
object-fit: contain;
|
|
139
|
+
}
|
|
140
|
+
.ps-watermark {
|
|
141
|
+
position: fixed;
|
|
142
|
+
right: 14px;
|
|
143
|
+
bottom: 14px;
|
|
144
|
+
z-index: 9999;
|
|
145
|
+
background: rgba(0,0,0,0.08);
|
|
146
|
+
border: 1px solid rgba(0,0,0,0.10);
|
|
147
|
+
color: var(--ps-text);
|
|
148
|
+
padding: 6px 10px;
|
|
149
|
+
border-radius: 8px;
|
|
150
|
+
font-size: 12px;
|
|
151
|
+
backdrop-filter: blur(2px);
|
|
152
|
+
}
|
|
153
|
+
html[data-theme="dark"] .ps-watermark {
|
|
154
|
+
background: rgba(255,255,255,0.10);
|
|
155
|
+
border-color: rgba(255,255,255,0.14);
|
|
156
|
+
}
|
|
157
|
+
.form-container {
|
|
158
|
+
max-width: 800px;
|
|
159
|
+
margin: 0 auto;
|
|
160
|
+
}
|
|
161
|
+
.field-error {
|
|
162
|
+
color: #dc3545;
|
|
163
|
+
font-size: 0.875rem;
|
|
164
|
+
margin-top: 0.25rem;
|
|
165
|
+
}
|
|
166
|
+
.section-header {
|
|
167
|
+
border-bottom: 2px solid #007bff;
|
|
168
|
+
padding-bottom: 0.5rem;
|
|
169
|
+
margin-bottom: 1.5rem;
|
|
170
|
+
margin-top: 2rem;
|
|
171
|
+
}
|
|
172
|
+
.section-header:first-child {
|
|
173
|
+
margin-top: 0;
|
|
174
|
+
}
|
|
175
|
+
.action-buttons {
|
|
176
|
+
margin-top: 2rem;
|
|
177
|
+
padding-top: 1rem;
|
|
178
|
+
border-top: 1px solid #dee2e6;
|
|
179
|
+
display: flex;
|
|
180
|
+
flex-wrap: wrap;
|
|
181
|
+
align-items: center;
|
|
182
|
+
gap: 0.5rem;
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
<div id="app">
|
|
188
|
+
<div class="container form-container">
|
|
189
|
+
<div class="ps-header">
|
|
190
|
+
<h1 class="mb-0">[[title]]</h1>
|
|
191
|
+
[[#logoUrl]]
|
|
192
|
+
<img class="ps-logo" src="[[logoUrl]]" alt="[[logoAlt]]">
|
|
193
|
+
[[/logoUrl]]
|
|
194
|
+
[[^logoUrl]]
|
|
195
|
+
[[^licensed]]
|
|
196
|
+
<svg class="ps-logo" viewBox="0 0 420 96" role="img" aria-label="PortalSmith">
|
|
197
|
+
<rect x="0" y="0" width="420" height="96" rx="14" fill="var(--ps-surface)" stroke="var(--ps-border)"/>
|
|
198
|
+
<text x="24" y="58" font-size="34" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-text)">PortalSmith</text>
|
|
199
|
+
<text x="24" y="78" font-size="14" font-family="Arial, Helvetica, sans-serif" fill="var(--ps-muted)">FormGen</text>
|
|
200
|
+
</svg>
|
|
201
|
+
[[/licensed]]
|
|
202
|
+
[[/logoUrl]]
|
|
203
|
+
</div>
|
|
204
|
+
<p v-if="description" class="text-muted mb-4">[[description]]</p>
|
|
205
|
+
|
|
206
|
+
<!-- Status Alert Area -->
|
|
207
|
+
<div
|
|
208
|
+
v-show="showAlert"
|
|
209
|
+
class="alert alert-dismissible fade show mb-3"
|
|
210
|
+
:class="'alert-' + (alertVariant || 'info')"
|
|
211
|
+
role="alert"
|
|
212
|
+
>
|
|
213
|
+
<span v-html="alertMessage"></span>
|
|
214
|
+
<button type="button" class="close" @click="showAlert = false" aria-label="Close">
|
|
215
|
+
<span aria-hidden="true">×</span>
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<!-- Result View -->
|
|
220
|
+
<div v-if="showResult" class="card mb-3">
|
|
221
|
+
<div class="card-body">
|
|
222
|
+
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
223
|
+
<h3 class="mb-0">Result</h3>
|
|
224
|
+
<div class="btn-group" role="group" aria-label="Result actions">
|
|
225
|
+
<button type="button" class="btn btn-outline-secondary" @click="showResult = false">
|
|
226
|
+
<i class="fa fa-arrow-left"></i> Back to form
|
|
227
|
+
</button>
|
|
228
|
+
<button type="button" class="btn btn-outline-primary" @click="downloadResultJson" :disabled="!lastResult">
|
|
229
|
+
<i class="fa fa-download"></i> Download JSON
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div v-if="lastResultStatus" class="text-muted mb-2">
|
|
234
|
+
HTTP Status: {{lastResultStatus}}
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<div class="btn-group mb-2" role="group" aria-label="Result view">
|
|
238
|
+
<button type="button" class="btn btn-sm" :class="resultView === 'table' ? 'btn-primary' : 'btn-outline-primary'" @click="resultView = 'table'">
|
|
239
|
+
Table
|
|
240
|
+
</button>
|
|
241
|
+
<button type="button" class="btn btn-sm" :class="resultView === 'json' ? 'btn-primary' : 'btn-outline-primary'" @click="resultView = 'json'">
|
|
242
|
+
JSON
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div v-if="resultView === 'table'">
|
|
247
|
+
<div v-if="resultRows.length === 0" class="text-muted">
|
|
248
|
+
No structured fields to display.
|
|
249
|
+
</div>
|
|
250
|
+
<div v-else class="table-responsive">
|
|
251
|
+
<table class="table table-sm table-bordered mb-0">
|
|
252
|
+
<thead>
|
|
253
|
+
<tr>
|
|
254
|
+
<th style="width:30%;">Field</th>
|
|
255
|
+
<th>Value</th>
|
|
256
|
+
</tr>
|
|
257
|
+
</thead>
|
|
258
|
+
<tbody>
|
|
259
|
+
<tr v-for="row in resultRows" :key="row.key">
|
|
260
|
+
<th class="font-weight-normal">{{row.label}}</th>
|
|
261
|
+
<td style="white-space:pre-wrap;">{{row.value}}</td>
|
|
262
|
+
</tr>
|
|
263
|
+
</tbody>
|
|
264
|
+
</table>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<pre v-else class="border rounded p-2 mb-0" style="white-space:pre-wrap;max-height:520px;overflow:auto;">{{formattedResult}}</pre>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<!-- Form Card -->
|
|
273
|
+
<div v-else class="card">
|
|
274
|
+
<div class="card-body">
|
|
275
|
+
<form @submit.prevent="handleSubmit">
|
|
276
|
+
<!-- Dynamic Sections -->
|
|
277
|
+
<div v-for="section in schema.sections" :key="section.id" class="form-section">
|
|
278
|
+
<h3 v-if="section.title" class="section-header">{{section.title}}</h3>
|
|
279
|
+
<p v-if="section.description" class="text-muted mb-3">{{section.description}}</p>
|
|
280
|
+
|
|
281
|
+
<!-- Dynamic Fields -->
|
|
282
|
+
<div v-for="field in visibleFieldsBySection[section.id]" :key="field.id" class="form-group">
|
|
283
|
+
<label :for="field.id" class="font-weight-bold">
|
|
284
|
+
{{field.label || field.id}}
|
|
285
|
+
<span v-if="field.required" class="text-danger">*</span>
|
|
286
|
+
</label>
|
|
287
|
+
|
|
288
|
+
<!-- Text -->
|
|
289
|
+
<input
|
|
290
|
+
v-if="field.type === 'text'"
|
|
291
|
+
:id="field.id"
|
|
292
|
+
v-model="formData[field.id]"
|
|
293
|
+
:type="field.inputType || 'text'"
|
|
294
|
+
:placeholder="field.placeholder"
|
|
295
|
+
class="form-control"
|
|
296
|
+
@blur="validateField(field)"
|
|
297
|
+
>
|
|
298
|
+
|
|
299
|
+
<!-- Textarea -->
|
|
300
|
+
<textarea
|
|
301
|
+
v-else-if="field.type === 'textarea'"
|
|
302
|
+
:id="field.id"
|
|
303
|
+
v-model="formData[field.id]"
|
|
304
|
+
:placeholder="field.placeholder"
|
|
305
|
+
class="form-control"
|
|
306
|
+
:rows="field.rows || 3"
|
|
307
|
+
@blur="validateField(field)"
|
|
308
|
+
></textarea>
|
|
309
|
+
|
|
310
|
+
<!-- Number -->
|
|
311
|
+
<input
|
|
312
|
+
v-else-if="field.type === 'number'"
|
|
313
|
+
:id="field.id"
|
|
314
|
+
v-model.number="formData[field.id]"
|
|
315
|
+
type="number"
|
|
316
|
+
:placeholder="field.placeholder"
|
|
317
|
+
:min="field.min"
|
|
318
|
+
:max="field.max"
|
|
319
|
+
:step="field.step"
|
|
320
|
+
class="form-control"
|
|
321
|
+
@blur="validateField(field)"
|
|
322
|
+
>
|
|
323
|
+
|
|
324
|
+
<!-- Select -->
|
|
325
|
+
<select
|
|
326
|
+
v-else-if="field.type === 'select'"
|
|
327
|
+
:id="field.id"
|
|
328
|
+
v-model="formData[field.id]"
|
|
329
|
+
class="form-control"
|
|
330
|
+
@change="validateField(field)"
|
|
331
|
+
>
|
|
332
|
+
<option value="">-- select --</option>
|
|
333
|
+
<option
|
|
334
|
+
v-for="(opt, optIdx) in (field.options || [])"
|
|
335
|
+
:key="field.id + '-opt-' + optIdx"
|
|
336
|
+
:value="opt.value"
|
|
337
|
+
>{{opt.text}}</option>
|
|
338
|
+
</select>
|
|
339
|
+
|
|
340
|
+
<!-- Checkbox -->
|
|
341
|
+
<div v-else-if="field.type === 'checkbox'" class="custom-control custom-checkbox">
|
|
342
|
+
<input
|
|
343
|
+
type="checkbox"
|
|
344
|
+
class="custom-control-input"
|
|
345
|
+
:id="field.id"
|
|
346
|
+
v-model="formData[field.id]"
|
|
347
|
+
@change="validateField(field)"
|
|
348
|
+
>
|
|
349
|
+
<label class="custom-control-label" :for="field.id">{{field.checkboxLabel || field.label}}</label>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<!-- Radio -->
|
|
353
|
+
<div v-else-if="field.type === 'radio'">
|
|
354
|
+
<div
|
|
355
|
+
v-for="(opt, optIdx) in (field.options || [])"
|
|
356
|
+
:key="field.id + '-radio-' + optIdx"
|
|
357
|
+
class="custom-control custom-radio"
|
|
358
|
+
>
|
|
359
|
+
<input
|
|
360
|
+
type="radio"
|
|
361
|
+
class="custom-control-input"
|
|
362
|
+
:id="field.id + '-radio-' + optIdx"
|
|
363
|
+
:name="field.id"
|
|
364
|
+
:value="opt.value"
|
|
365
|
+
v-model="formData[field.id]"
|
|
366
|
+
@change="validateField(field)"
|
|
367
|
+
>
|
|
368
|
+
<label class="custom-control-label" :for="field.id + '-radio-' + optIdx">{{opt.text}}</label>
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
|
|
372
|
+
<!-- Date -->
|
|
373
|
+
<input
|
|
374
|
+
v-else-if="field.type === 'date'"
|
|
375
|
+
:id="field.id"
|
|
376
|
+
v-model="formData[field.id]"
|
|
377
|
+
type="date"
|
|
378
|
+
class="form-control"
|
|
379
|
+
@blur="validateField(field)"
|
|
380
|
+
>
|
|
381
|
+
|
|
382
|
+
<!-- Key-Value Pairs -->
|
|
383
|
+
<div v-else-if="field.type === 'keyvalue'">
|
|
384
|
+
<!-- Individual Pairs Mode -->
|
|
385
|
+
<div v-if="!field.keyvalueMode || field.keyvalueMode === 'pairs'" class="keyvalue-pairs">
|
|
386
|
+
<div
|
|
387
|
+
v-for="(pair, pairIdx) in formData[field.id]"
|
|
388
|
+
:key="field.id + '-pair-' + pairIdx"
|
|
389
|
+
class="mb-2 border rounded p-2 bg-light"
|
|
390
|
+
>
|
|
391
|
+
<div class="form-row">
|
|
392
|
+
<div class="col-md-5 mb-2">
|
|
393
|
+
<label class="small mb-1">Key</label>
|
|
394
|
+
<input type="text" class="form-control form-control-sm" v-model="pair.key" placeholder="key">
|
|
395
|
+
</div>
|
|
396
|
+
<div class="col-md-7 mb-2">
|
|
397
|
+
<label class="small mb-1">Value</label>
|
|
398
|
+
<input type="text" class="form-control form-control-sm" v-model="pair.value" placeholder="value">
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
<button type="button" class="btn btn-outline-danger btn-sm" @click="removeKeyValuePair(field.id, pairIdx)">
|
|
402
|
+
Remove
|
|
403
|
+
</button>
|
|
404
|
+
</div>
|
|
405
|
+
<button type="button" class="btn btn-outline-primary btn-sm" @click="addKeyValuePair(field.id)">
|
|
406
|
+
Add Pair
|
|
407
|
+
</button>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
<!-- Delimiter Text Mode -->
|
|
411
|
+
<div v-else-if="field.keyvalueMode === 'delimiter'">
|
|
412
|
+
<textarea
|
|
413
|
+
:id="field.id"
|
|
414
|
+
v-model="formData[field.id]"
|
|
415
|
+
class="form-control"
|
|
416
|
+
:placeholder="'key' + (field.keyvalueDelimiter || '=') + 'value (one per line)'"
|
|
417
|
+
:rows="field.rows || 5"
|
|
418
|
+
@blur="validateField(field)"
|
|
419
|
+
></textarea>
|
|
420
|
+
<small class="form-text text-muted mt-2">
|
|
421
|
+
Enter key-value pairs, one per line, using format: <code>key{{field.keyvalueDelimiter || '='}}value</code>
|
|
422
|
+
</small>
|
|
423
|
+
</div>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<!-- Unknown -->
|
|
427
|
+
<div v-else class="alert alert-warning py-2">
|
|
428
|
+
Unknown field type: {{field.type}} for field {{field.id}}
|
|
429
|
+
</div>
|
|
430
|
+
|
|
431
|
+
<!-- Field Error Message -->
|
|
432
|
+
<div v-if="fieldErrors[field.id]" class="field-error">
|
|
433
|
+
{{fieldErrors[field.id]}}
|
|
434
|
+
</div>
|
|
435
|
+
|
|
436
|
+
<!-- Field Help Text -->
|
|
437
|
+
<small v-if="field.help" class="form-text text-muted">
|
|
438
|
+
{{field.help}}
|
|
439
|
+
</small>
|
|
440
|
+
</div>
|
|
441
|
+
</div>
|
|
442
|
+
|
|
443
|
+
<!-- Action Buttons -->
|
|
444
|
+
<div class="action-buttons">
|
|
445
|
+
<div class="btn-group mr-2 mb-2" role="group" aria-label="Draft actions">
|
|
446
|
+
<button type="button" class="btn btn-primary" @click="handleSaveDraft" :disabled="saving">
|
|
447
|
+
<i class="fa fa-save"></i> Save Draft
|
|
448
|
+
</button>
|
|
449
|
+
<!-- Use a label+input to open file picker reliably (some browsers block programmatic input.click()) -->
|
|
450
|
+
<label
|
|
451
|
+
for="ps-draft-file-input"
|
|
452
|
+
class="btn btn-secondary mb-0"
|
|
453
|
+
:class="loading ? 'disabled' : ''"
|
|
454
|
+
:style="loading ? { pointerEvents: 'none', opacity: 0.65 } : null"
|
|
455
|
+
>
|
|
456
|
+
<i class="fa fa-folder-open"></i> Load Draft
|
|
457
|
+
</label>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
<input
|
|
461
|
+
id="ps-draft-file-input"
|
|
462
|
+
type="file"
|
|
463
|
+
accept="application/json,.json"
|
|
464
|
+
style="display:none"
|
|
465
|
+
@change="handleDraftFileSelected"
|
|
466
|
+
>
|
|
467
|
+
|
|
468
|
+
<button type="submit" class="btn btn-success mr-2 mb-2" :disabled="submitting || hasErrors">
|
|
469
|
+
<i class="fa fa-paper-plane"></i> Submit
|
|
470
|
+
</button>
|
|
471
|
+
|
|
472
|
+
<button type="button" class="btn btn-outline-warning mr-2 mb-2" @click="handleClearForm" :disabled="submitting">
|
|
473
|
+
<i class="fa fa-eraser"></i> Clear Form
|
|
474
|
+
</button>
|
|
475
|
+
|
|
476
|
+
<!-- CopyBlock Actions -->
|
|
477
|
+
<template v-for="action in copyBlockActions">
|
|
478
|
+
<button
|
|
479
|
+
type="button"
|
|
480
|
+
:key="action.id"
|
|
481
|
+
class="btn btn-info mr-2 mb-2"
|
|
482
|
+
@click="handleCopyBlock(action)"
|
|
483
|
+
>
|
|
484
|
+
<i class="fa fa-copy"></i> {{action.label || 'Copy Block'}}
|
|
485
|
+
</button>
|
|
486
|
+
</template>
|
|
487
|
+
|
|
488
|
+
<!-- Export Buttons -->
|
|
489
|
+
<div class="dropdown mb-2 d-inline-block" v-if="exportFormats.length > 0">
|
|
490
|
+
<button
|
|
491
|
+
type="button"
|
|
492
|
+
class="btn btn-outline-secondary dropdown-toggle"
|
|
493
|
+
data-toggle="dropdown"
|
|
494
|
+
aria-haspopup="true"
|
|
495
|
+
aria-expanded="false"
|
|
496
|
+
>
|
|
497
|
+
<i class="fa fa-download"></i> Export
|
|
498
|
+
</button>
|
|
499
|
+
<div class="dropdown-menu">
|
|
500
|
+
<button
|
|
501
|
+
v-for="format in exportFormats"
|
|
502
|
+
:key="format"
|
|
503
|
+
type="button"
|
|
504
|
+
class="dropdown-item"
|
|
505
|
+
@click="handleExport(format)"
|
|
506
|
+
>
|
|
507
|
+
{{format.toUpperCase()}}
|
|
508
|
+
</button>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
</form>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
<!-- License watermark (FREE mode) -->
|
|
516
|
+
<div v-show="license && license.watermark" class="ps-watermark">
|
|
517
|
+
Powered by <strong>PortalSmith FormGen</strong>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
<!-- jQuery (required by Bootstrap-Vue) -->
|
|
523
|
+
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
|
524
|
+
|
|
525
|
+
<!-- Vue 2 -->
|
|
526
|
+
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.14/dist/vue.min.js"></script>
|
|
527
|
+
|
|
528
|
+
<!-- Bootstrap 4 JS (required for Bootstrap-Vue) -->
|
|
529
|
+
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-Fy6S3B9q64WdZWQUiU+q4/2Lc9npb8tCaSX9FK7E8HnRr0Jz8D6OP9dO5Vg3Q9ct" crossorigin="anonymous"></script>
|
|
530
|
+
|
|
531
|
+
<!-- Bootstrap-Vue JS -->
|
|
532
|
+
<script src="https://unpkg.com/bootstrap-vue@2.23.1/dist/bootstrap-vue.min.js"></script>
|
|
533
|
+
|
|
534
|
+
<!-- uibuilder client script (uibuilder v7 IIFE) -->
|
|
535
|
+
<script defer src="../uibuilder/uibuilder.iife.min.js"></script>
|
|
536
|
+
|
|
537
|
+
<!-- Portal Logic -->
|
|
538
|
+
<script defer src="./index.js"></script>
|
|
539
|
+
</body>
|
|
540
|
+
</html>
|
|
541
|
+
|