@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,1135 @@
|
|
|
1
|
+
// PortalSmith FormGen Portal Runtime
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
// Generated: [[timestamp]]
|
|
4
|
+
// Instance: [[instanceName]]
|
|
5
|
+
// Form ID: [[formId]]
|
|
6
|
+
|
|
7
|
+
(function() {
|
|
8
|
+
'use strict';
|
|
9
|
+
|
|
10
|
+
// Configuration from runtime
|
|
11
|
+
const CONFIG = {
|
|
12
|
+
formId: '[[formId]]',
|
|
13
|
+
storageMode: '[[storageMode]]',
|
|
14
|
+
exportFormats: __EXPORT_FORMATS_JSON__,
|
|
15
|
+
baseUrl: '[[baseUrl]]',
|
|
16
|
+
themeMode: '[[themeMode]]',
|
|
17
|
+
license: __LICENSE_JSON__,
|
|
18
|
+
submitMode: '[[submitMode]]', // 'uibuilder' | 'http'
|
|
19
|
+
submitUrl: '[[submitUrl]]', // required when submitMode === 'http'
|
|
20
|
+
submitHeadersJson: '[[submitHeadersJson]]' // optional JSON string
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
24
|
+
function timestampForFilename() {
|
|
25
|
+
const d = new Date();
|
|
26
|
+
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function escapeCsvCell(val) {
|
|
30
|
+
if (val === null || val === undefined) return '';
|
|
31
|
+
const s = String(val).replace(/\r?\n/g, '\\n');
|
|
32
|
+
// Quote if contains comma, quote, or newline marker
|
|
33
|
+
if (/[,"\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
|
34
|
+
return s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function downloadTextFile(filename, mime, text) {
|
|
38
|
+
const blob = new Blob([text], { type: mime });
|
|
39
|
+
const url = URL.createObjectURL(blob);
|
|
40
|
+
const a = document.createElement('a');
|
|
41
|
+
a.href = url;
|
|
42
|
+
a.download = filename;
|
|
43
|
+
document.body.appendChild(a);
|
|
44
|
+
a.click();
|
|
45
|
+
document.body.removeChild(a);
|
|
46
|
+
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function canUseLocalStorage() {
|
|
50
|
+
try {
|
|
51
|
+
const k = '__ps_test__';
|
|
52
|
+
localStorage.setItem(k, '1');
|
|
53
|
+
localStorage.removeItem(k);
|
|
54
|
+
return true;
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureDraftFileInput(onLoadJson) {
|
|
61
|
+
let input = document.getElementById('ps-draft-file-input');
|
|
62
|
+
if (!input) {
|
|
63
|
+
input = document.createElement('input');
|
|
64
|
+
input.type = 'file';
|
|
65
|
+
input.accept = 'application/json,.json';
|
|
66
|
+
input.id = 'ps-draft-file-input';
|
|
67
|
+
input.style.position = 'fixed';
|
|
68
|
+
input.style.left = '-9999px';
|
|
69
|
+
input.style.top = '-9999px';
|
|
70
|
+
document.body.appendChild(input);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
input.onchange = function() {
|
|
74
|
+
const file = input.files && input.files[0];
|
|
75
|
+
// Reset value so selecting the same file twice still triggers change
|
|
76
|
+
input.value = '';
|
|
77
|
+
if (!file) return;
|
|
78
|
+
|
|
79
|
+
// Prefer File.text() (modern), fallback to FileReader
|
|
80
|
+
if (typeof file.text === 'function') {
|
|
81
|
+
file.text()
|
|
82
|
+
.then(text => onLoadJson(JSON.parse(text)))
|
|
83
|
+
.catch(e => onLoadJson(null, e));
|
|
84
|
+
} else {
|
|
85
|
+
try {
|
|
86
|
+
const reader = new FileReader();
|
|
87
|
+
reader.onload = function() {
|
|
88
|
+
try {
|
|
89
|
+
onLoadJson(JSON.parse(String(reader.result || '')));
|
|
90
|
+
} catch (e) {
|
|
91
|
+
onLoadJson(null, e);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
reader.onerror = function() {
|
|
95
|
+
onLoadJson(null, new Error('Failed to read file'));
|
|
96
|
+
};
|
|
97
|
+
reader.readAsText(file);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
onLoadJson(null, e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return input;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function parseKeyValueDelimiter(textValue, delimiter) {
|
|
108
|
+
const out = [];
|
|
109
|
+
const text = (textValue === null || textValue === undefined) ? '' : String(textValue);
|
|
110
|
+
const del = delimiter || '=';
|
|
111
|
+
text.split('\n')
|
|
112
|
+
.map(l => l.trim())
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.forEach(line => {
|
|
115
|
+
const parts = line.split(del);
|
|
116
|
+
if (parts.length >= 2) {
|
|
117
|
+
const key = parts[0].trim();
|
|
118
|
+
const value = parts.slice(1).join(del).trim();
|
|
119
|
+
if (key) out.push({ key, value });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function keyvalueDelimiterLines(textValue) {
|
|
126
|
+
// Keep each non-empty line as its own string: ["k=v", "a=b"]
|
|
127
|
+
const text = (textValue === null || textValue === undefined) ? '' : String(textValue);
|
|
128
|
+
return text
|
|
129
|
+
.split('\n')
|
|
130
|
+
.map(l => l.trim())
|
|
131
|
+
.filter(Boolean);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function processedFormData(schema, formData) {
|
|
135
|
+
// Normalize form payload for submit/export.
|
|
136
|
+
// NOTE: For keyvalue delimiter-mode, we convert textarea text into string[] (one entry per line).
|
|
137
|
+
const processed = { ...formData };
|
|
138
|
+
(schema.sections || []).forEach(section => {
|
|
139
|
+
(section.fields || []).forEach(field => {
|
|
140
|
+
if (!field || !field.id) return;
|
|
141
|
+
if (field.type === 'keyvalue') {
|
|
142
|
+
const mode = field.keyvalueMode || 'pairs';
|
|
143
|
+
if (mode === 'delimiter') {
|
|
144
|
+
processed[field.id] = keyvalueDelimiterLines(processed[field.id]);
|
|
145
|
+
} else {
|
|
146
|
+
// pairs mode: normalize to array and strip empty keys
|
|
147
|
+
const arr = Array.isArray(processed[field.id]) ? processed[field.id] : [];
|
|
148
|
+
processed[field.id] = arr
|
|
149
|
+
.filter(p => p && p.key && String(p.key).trim())
|
|
150
|
+
.map(p => ({ key: String(p.key).trim(), value: (p.value === null || p.value === undefined) ? '' : String(p.value) }));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
return processed;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function schemaFieldMap(schema) {
|
|
159
|
+
const map = {};
|
|
160
|
+
(schema.sections || []).forEach(section => {
|
|
161
|
+
(section.fields || []).forEach(field => {
|
|
162
|
+
if (field && field.id) map[field.id] = field;
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return map;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function exportAsJson(schema, formData) {
|
|
169
|
+
const payload = processedFormData(schema, formData);
|
|
170
|
+
const filename = `${CONFIG.formId || 'form'}-${timestampForFilename()}.json`;
|
|
171
|
+
downloadTextFile(filename, 'application/json;charset=utf-8', JSON.stringify(payload, null, 2));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function exportAsCsv(schema, formData) {
|
|
175
|
+
const payload = processedFormData(schema, formData);
|
|
176
|
+
const keys = Object.keys(payload);
|
|
177
|
+
const header = keys.map(escapeCsvCell).join(',');
|
|
178
|
+
const row = keys.map(k => {
|
|
179
|
+
const v = payload[k];
|
|
180
|
+
// keyvalue arrays export as JSON for fidelity
|
|
181
|
+
if (Array.isArray(v)) return escapeCsvCell(JSON.stringify(v));
|
|
182
|
+
if (typeof v === 'object' && v !== null) return escapeCsvCell(JSON.stringify(v));
|
|
183
|
+
return escapeCsvCell(v);
|
|
184
|
+
}).join(',');
|
|
185
|
+
const filename = `${CONFIG.formId || 'form'}-${timestampForFilename()}.csv`;
|
|
186
|
+
downloadTextFile(filename, 'text/csv;charset=utf-8', `${header}\n${row}\n`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function exportAsHtml(schema, formData) {
|
|
190
|
+
const payload = processedFormData(schema, formData);
|
|
191
|
+
const fields = schemaFieldMap(schema);
|
|
192
|
+
const rows = Object.keys(payload).map(id => {
|
|
193
|
+
const field = fields[id] || {};
|
|
194
|
+
const label = (field.label || id);
|
|
195
|
+
const v = payload[id];
|
|
196
|
+
const display = (Array.isArray(v) || (typeof v === 'object' && v !== null)) ? JSON.stringify(v, null, 2) : String(v ?? '');
|
|
197
|
+
return `<tr><th style="text-align:left;vertical-align:top;padding:6px 10px;border:1px solid #ccc;">${escapeHtml(label)}</th><td style="white-space:pre-wrap;padding:6px 10px;border:1px solid #ccc;">${escapeHtml(display)}</td></tr>`;
|
|
198
|
+
}).join('\n');
|
|
199
|
+
|
|
200
|
+
const html = `<!doctype html>
|
|
201
|
+
<html lang="en">
|
|
202
|
+
<head>
|
|
203
|
+
<meta charset="utf-8">
|
|
204
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
205
|
+
<title>${escapeHtml(CONFIG.formId || 'form')} export</title>
|
|
206
|
+
</head>
|
|
207
|
+
<body style="font-family:system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;padding:16px;">
|
|
208
|
+
<h1 style="margin:0 0 8px 0;">${escapeHtml(CONFIG.formId || 'form')} export</h1>
|
|
209
|
+
<div style="color:#666;margin-bottom:12px;">Generated: ${escapeHtml(new Date().toISOString())}</div>
|
|
210
|
+
<table style="border-collapse:collapse;width:100%;">${rows}</table>
|
|
211
|
+
</body>
|
|
212
|
+
</html>`;
|
|
213
|
+
|
|
214
|
+
const filename = `${CONFIG.formId || 'form'}-${timestampForFilename()}.html`;
|
|
215
|
+
downloadTextFile(filename, 'text/html;charset=utf-8', html);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function escapeHtml(s) {
|
|
219
|
+
return String(s)
|
|
220
|
+
.replace(/&/g, '&')
|
|
221
|
+
.replace(/</g, '<')
|
|
222
|
+
.replace(/>/g, '>')
|
|
223
|
+
.replace(/"/g, '"')
|
|
224
|
+
.replace(/'/g, ''');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function applyTheme() {
|
|
228
|
+
try {
|
|
229
|
+
// Optional override via localStorage (lets you tweak without regenerating)
|
|
230
|
+
const stored = localStorage.getItem('portalsmith:theme');
|
|
231
|
+
const desired = (stored && stored.trim()) ? stored.trim().toLowerCase() : (CONFIG.themeMode || 'auto');
|
|
232
|
+
|
|
233
|
+
if (desired === 'light' || desired === 'dark' || desired === 'auto') {
|
|
234
|
+
document.documentElement.setAttribute('data-theme', desired);
|
|
235
|
+
}
|
|
236
|
+
} catch (e) {
|
|
237
|
+
// ignore (storage blocked, etc.)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Vue app instance
|
|
242
|
+
let app;
|
|
243
|
+
|
|
244
|
+
// uibuilder instance
|
|
245
|
+
let uibuilderInstance;
|
|
246
|
+
|
|
247
|
+
// Initialize when DOM is ready
|
|
248
|
+
if (document.readyState === 'loading') {
|
|
249
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
250
|
+
} else {
|
|
251
|
+
init();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function init() {
|
|
255
|
+
try {
|
|
256
|
+
applyTheme();
|
|
257
|
+
|
|
258
|
+
if (CONFIG.license && CONFIG.license.brandingLocked && CONFIG.license.brandingAttempted) {
|
|
259
|
+
console.warn('PortalSmith FormGen: custom branding is disabled in Free mode (watermarked). Using default branding.');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Wait for uibuilder to be available (uibuilder v7.5.0)
|
|
263
|
+
// uibuilder.start() is void - the global uibuilder object IS the instance
|
|
264
|
+
if (typeof uibuilder === 'undefined') {
|
|
265
|
+
// Wait for script to load
|
|
266
|
+
await new Promise(resolve => {
|
|
267
|
+
let attempts = 0;
|
|
268
|
+
const checkUibuilder = setInterval(() => {
|
|
269
|
+
if (typeof uibuilder !== 'undefined') {
|
|
270
|
+
clearInterval(checkUibuilder);
|
|
271
|
+
resolve();
|
|
272
|
+
} else if (++attempts > 20) {
|
|
273
|
+
clearInterval(checkUibuilder);
|
|
274
|
+
console.warn('uibuilder not found, some features may not work');
|
|
275
|
+
resolve();
|
|
276
|
+
}
|
|
277
|
+
}, 100);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Start uibuilder (v7.5.0: start() is void, uibuilder global is the instance)
|
|
282
|
+
if (typeof uibuilder !== 'undefined' && typeof uibuilder.start === 'function') {
|
|
283
|
+
uibuilder.start();
|
|
284
|
+
uibuilderInstance = uibuilder; // Use the global object as the instance
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Load schema
|
|
288
|
+
const schema = await loadSchema();
|
|
289
|
+
|
|
290
|
+
// Initialize Vue app
|
|
291
|
+
initVueApp(schema);
|
|
292
|
+
|
|
293
|
+
// Set up uibuilder message handlers
|
|
294
|
+
setupUibuilderHandlers();
|
|
295
|
+
|
|
296
|
+
// Try to load draft on startup
|
|
297
|
+
if (app) {
|
|
298
|
+
app.loadDraftFromStorage();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error('Portal initialization error:', error);
|
|
303
|
+
if (app) {
|
|
304
|
+
showAlert('Failed to initialize portal: ' + error.message, 'danger');
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function loadSchema() {
|
|
310
|
+
try {
|
|
311
|
+
const response = await fetch('./form.schema.json');
|
|
312
|
+
if (!response.ok) {
|
|
313
|
+
throw new Error(`Failed to load schema: ${response.statusText}`);
|
|
314
|
+
}
|
|
315
|
+
return await response.json();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
console.error('Schema load error:', error);
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function initVueApp(schema) {
|
|
323
|
+
// Initialize formData with default values from schema
|
|
324
|
+
const formData = {};
|
|
325
|
+
const fieldErrors = {};
|
|
326
|
+
|
|
327
|
+
console.log('Starting formData initialization. Schema sections:', schema.sections.length);
|
|
328
|
+
|
|
329
|
+
schema.sections.forEach((section, sectionIdx) => {
|
|
330
|
+
console.log(`Processing section ${sectionIdx}: ${section.id || 'unnamed'}, fields: ${section.fields ? section.fields.length : 0}`);
|
|
331
|
+
|
|
332
|
+
if (!section.fields || !Array.isArray(section.fields)) {
|
|
333
|
+
console.warn(`Section ${sectionIdx} has no fields array`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
section.fields.forEach((field, fieldIdx) => {
|
|
338
|
+
try {
|
|
339
|
+
if (!field || !field.id) {
|
|
340
|
+
console.warn(`Section ${sectionIdx}, field ${fieldIdx} is missing id:`, field);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
console.log(` Initializing field ${fieldIdx}: ${field.id}, type: ${field.type || 'undefined'}`);
|
|
345
|
+
|
|
346
|
+
// Ensure keyvalue fields have mode set (use Object.defineProperty for Vue reactivity)
|
|
347
|
+
if (field.type === 'keyvalue') {
|
|
348
|
+
if (!field.hasOwnProperty('keyvalueMode')) {
|
|
349
|
+
Object.defineProperty(field, 'keyvalueMode', {
|
|
350
|
+
value: field.keyvalueMode || 'pairs',
|
|
351
|
+
writable: true,
|
|
352
|
+
enumerable: true,
|
|
353
|
+
configurable: true
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
if (!field.hasOwnProperty('keyvalueDelimiter')) {
|
|
357
|
+
Object.defineProperty(field, 'keyvalueDelimiter', {
|
|
358
|
+
value: field.keyvalueDelimiter || '=',
|
|
359
|
+
writable: true,
|
|
360
|
+
enumerable: true,
|
|
361
|
+
configurable: true
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// Debug: log keyvalue field initialization
|
|
365
|
+
console.log(' Keyvalue field:', field.id, 'mode:', field.keyvalueMode, 'delimiter:', field.keyvalueDelimiter);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Set default value based on field type
|
|
369
|
+
if (field.type === 'checkbox') {
|
|
370
|
+
formData[field.id] = field.defaultValue !== undefined ? field.defaultValue : false;
|
|
371
|
+
console.log(` Checkbox field ${field.id} initialized to:`, formData[field.id]);
|
|
372
|
+
} else if (field.type === 'number') {
|
|
373
|
+
formData[field.id] = field.defaultValue !== undefined ? field.defaultValue : null;
|
|
374
|
+
console.log(` Number field ${field.id} initialized to:`, formData[field.id]);
|
|
375
|
+
} else if (field.type === 'keyvalue') {
|
|
376
|
+
if (field.keyvalueMode === 'delimiter') {
|
|
377
|
+
// Delimiter mode: initialize as empty string or default value
|
|
378
|
+
if (field.defaultValue !== undefined) {
|
|
379
|
+
formData[field.id] = String(field.defaultValue);
|
|
380
|
+
} else {
|
|
381
|
+
// If pairs exist, convert them to delimiter format
|
|
382
|
+
if (field.pairs && field.pairs.length > 0) {
|
|
383
|
+
const delimiter = field.keyvalueDelimiter || '=';
|
|
384
|
+
formData[field.id] = field.pairs
|
|
385
|
+
.filter(p => p && p.key && p.key.trim())
|
|
386
|
+
.map(p => `${p.key}${delimiter}${p.value || ''}`)
|
|
387
|
+
.join('\n');
|
|
388
|
+
} else {
|
|
389
|
+
formData[field.id] = '';
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
console.log(' Delimiter mode initialized:', field.id, 'formData value:', formData[field.id]);
|
|
393
|
+
} else {
|
|
394
|
+
// Pairs mode: initialize with default pairs or empty array
|
|
395
|
+
if (field.defaultValue && Array.isArray(field.defaultValue)) {
|
|
396
|
+
formData[field.id] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
397
|
+
} else if (field.pairs && field.pairs.length > 0) {
|
|
398
|
+
// Use pairs from schema as initial values
|
|
399
|
+
formData[field.id] = field.pairs.map(p => ({ key: p.key || '', value: p.value || '' }));
|
|
400
|
+
} else {
|
|
401
|
+
formData[field.id] = [{ key: '', value: '' }];
|
|
402
|
+
}
|
|
403
|
+
console.log(' Pairs mode initialized:', field.id, 'pairs:', formData[field.id].length);
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
// For all other field types (text, select, radio, date, textarea), always initialize
|
|
407
|
+
// This ensures Vue reactivity works even if defaultValue is undefined
|
|
408
|
+
const defaultValue = field.defaultValue !== undefined ? field.defaultValue : '';
|
|
409
|
+
formData[field.id] = defaultValue;
|
|
410
|
+
console.log(` Field ${field.id} (${field.type}) initialized to:`, typeof defaultValue === 'string' ? `"${defaultValue}"` : defaultValue);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Ensure fieldErrors is always initialized
|
|
414
|
+
if (!fieldErrors.hasOwnProperty(field.id)) {
|
|
415
|
+
fieldErrors[field.id] = '';
|
|
416
|
+
}
|
|
417
|
+
} catch (error) {
|
|
418
|
+
console.error(`Error initializing field ${fieldIdx} in section ${sectionIdx}:`, error, field);
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Debug: Verify all fields are initialized
|
|
424
|
+
console.log('FormData initialized:', Object.keys(formData).length, 'fields');
|
|
425
|
+
console.log('Sample formData:', Object.keys(formData).slice(0, 5).map(k => `${k}: ${typeof formData[k]}`));
|
|
426
|
+
|
|
427
|
+
// Debug: Log schema before Vue initialization
|
|
428
|
+
console.log('Schema sections count:', schema.sections.length);
|
|
429
|
+
schema.sections.forEach((section, idx) => {
|
|
430
|
+
console.log(`Section ${idx}: ${section.id}, fields: ${section.fields.length}`);
|
|
431
|
+
section.fields.forEach(field => {
|
|
432
|
+
console.log(` Field: ${field.id}, type: ${field.type}`);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Ensure ALL fields are initialized in formData before Vue initialization
|
|
437
|
+
// This is critical for Vue reactivity - all properties must exist on the data object
|
|
438
|
+
console.log('=== Second pass: Ensuring all fields are in formData ===');
|
|
439
|
+
schema.sections.forEach((section, sectionIdx) => {
|
|
440
|
+
section.fields.forEach((field, fieldIdx) => {
|
|
441
|
+
// Double-check: ensure every field has a value in formData
|
|
442
|
+
if (!formData.hasOwnProperty(field.id)) {
|
|
443
|
+
console.warn(`Field ${field.id} (${field.type}) was NOT initialized in first pass! Initializing now...`);
|
|
444
|
+
// Initialize based on field type
|
|
445
|
+
if (field.type === 'checkbox') {
|
|
446
|
+
formData[field.id] = false;
|
|
447
|
+
} else if (field.type === 'number') {
|
|
448
|
+
formData[field.id] = null;
|
|
449
|
+
} else if (field.type === 'select' || field.type === 'radio') {
|
|
450
|
+
formData[field.id] = '';
|
|
451
|
+
} else {
|
|
452
|
+
formData[field.id] = '';
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
console.log(`Field ${field.id} (${field.type}) already in formData:`, typeof formData[field.id], formData[field.id]);
|
|
456
|
+
}
|
|
457
|
+
// Ensure fieldErrors exists for every field
|
|
458
|
+
if (!fieldErrors.hasOwnProperty(field.id)) {
|
|
459
|
+
fieldErrors[field.id] = '';
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
// Ensure Bootstrap-Vue is registered (needed for <b-card>, <b-button>, etc.)
|
|
465
|
+
if (typeof Vue !== 'undefined' && typeof BootstrapVue !== 'undefined' && typeof Vue.use === 'function') {
|
|
466
|
+
try { Vue.use(BootstrapVue); } catch (e) { /* ignore */ }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
app = new Vue({
|
|
470
|
+
el: '#app',
|
|
471
|
+
data: {
|
|
472
|
+
license: CONFIG.license,
|
|
473
|
+
schema: schema,
|
|
474
|
+
formData: formData,
|
|
475
|
+
fieldErrors: fieldErrors,
|
|
476
|
+
showAlert: false,
|
|
477
|
+
alertMessage: '',
|
|
478
|
+
alertVariant: 'info',
|
|
479
|
+
saving: false,
|
|
480
|
+
loading: false,
|
|
481
|
+
submitting: false,
|
|
482
|
+
showResult: false,
|
|
483
|
+
lastResult: null,
|
|
484
|
+
lastResultStatus: null,
|
|
485
|
+
resultView: 'table',
|
|
486
|
+
copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
|
|
487
|
+
exportFormats: CONFIG.exportFormats
|
|
488
|
+
},
|
|
489
|
+
computed: {
|
|
490
|
+
description() {
|
|
491
|
+
return this.schema.description || '';
|
|
492
|
+
},
|
|
493
|
+
hasErrors() {
|
|
494
|
+
return Object.values(this.fieldErrors).some(err => err !== '');
|
|
495
|
+
},
|
|
496
|
+
// Computed property to get visible fields for each section
|
|
497
|
+
visibleFieldsBySection() {
|
|
498
|
+
const result = {};
|
|
499
|
+
this.schema.sections.forEach(section => {
|
|
500
|
+
result[section.id] = section.fields.filter(field => this.shouldShowField(field));
|
|
501
|
+
});
|
|
502
|
+
return result;
|
|
503
|
+
}
|
|
504
|
+
,
|
|
505
|
+
formattedResult() {
|
|
506
|
+
if (this.lastResult === null || this.lastResult === undefined) return '';
|
|
507
|
+
try {
|
|
508
|
+
return JSON.stringify(this.lastResult, null, 2);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
return String(this.lastResult);
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
resultRows() {
|
|
514
|
+
const r = this.lastResult;
|
|
515
|
+
if (r === null || r === undefined) return [];
|
|
516
|
+
|
|
517
|
+
// If result is an object, show its top-level keys as rows
|
|
518
|
+
if (typeof r === 'object' && !Array.isArray(r)) {
|
|
519
|
+
return Object.keys(r).sort().map(k => ({
|
|
520
|
+
key: k,
|
|
521
|
+
label: k,
|
|
522
|
+
value: this.formatResultValue(r[k]),
|
|
523
|
+
}));
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// If result is an array, show a single row with joined values
|
|
527
|
+
if (Array.isArray(r)) {
|
|
528
|
+
return [{
|
|
529
|
+
key: '__array__',
|
|
530
|
+
label: 'items',
|
|
531
|
+
value: this.formatResultValue(r),
|
|
532
|
+
}];
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Primitive/string result
|
|
536
|
+
return [{
|
|
537
|
+
key: '__result__',
|
|
538
|
+
label: 'result',
|
|
539
|
+
value: this.formatResultValue(r),
|
|
540
|
+
}];
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
methods: {
|
|
544
|
+
handleClearForm() {
|
|
545
|
+
try {
|
|
546
|
+
if (!confirm('Clear all fields and reset the form to defaults?')) return;
|
|
547
|
+
this.resetForm();
|
|
548
|
+
showAlert('Form cleared', 'info');
|
|
549
|
+
} catch (e) {
|
|
550
|
+
showAlert('Failed to clear form: ' + e.message, 'danger');
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
formatResultValue(v) {
|
|
555
|
+
if (v === null || v === undefined) return '';
|
|
556
|
+
if (Array.isArray(v)) {
|
|
557
|
+
// Arrays: join using vertical bar separator
|
|
558
|
+
return v.map(item => {
|
|
559
|
+
if (item === null || item === undefined) return '';
|
|
560
|
+
if (typeof item === 'object') {
|
|
561
|
+
try { return JSON.stringify(item); } catch (e) { return String(item); }
|
|
562
|
+
}
|
|
563
|
+
return String(item);
|
|
564
|
+
}).filter(s => s !== '').join(' | ');
|
|
565
|
+
}
|
|
566
|
+
if (typeof v === 'object') {
|
|
567
|
+
try { return JSON.stringify(v, null, 0); } catch (e) { return String(v); }
|
|
568
|
+
}
|
|
569
|
+
return String(v);
|
|
570
|
+
},
|
|
571
|
+
|
|
572
|
+
downloadResultJson() {
|
|
573
|
+
if (!this.lastResult) return;
|
|
574
|
+
const filename = `${CONFIG.formId || 'form'}-result-${timestampForFilename()}.json`;
|
|
575
|
+
downloadTextFile(filename, 'application/json;charset=utf-8', JSON.stringify(this.lastResult, null, 2));
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
handleDraftFileSelected(evt) {
|
|
579
|
+
try {
|
|
580
|
+
this.loading = true;
|
|
581
|
+
const input = evt && evt.target;
|
|
582
|
+
const file = input && input.files && input.files[0];
|
|
583
|
+
if (input) input.value = ''; // allow selecting same file twice
|
|
584
|
+
if (!file) {
|
|
585
|
+
this.loading = false;
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const applyJson = (json) => {
|
|
590
|
+
const data = (json && typeof json === 'object')
|
|
591
|
+
? (json.data || json.formData || json.payload || json)
|
|
592
|
+
: null;
|
|
593
|
+
if (!data || typeof data !== 'object') {
|
|
594
|
+
throw new Error('Invalid draft JSON (missing data object)');
|
|
595
|
+
}
|
|
596
|
+
const merged = { ...this.formData, ...data };
|
|
597
|
+
this.$set(this, 'formData', merged);
|
|
598
|
+
showAlert(`Draft loaded from file (${Object.keys(data).length} fields)`, 'success');
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const fail = (e) => {
|
|
602
|
+
showAlert('Failed to load draft file: ' + (e && e.message ? e.message : String(e)), 'danger');
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
if (typeof file.text === 'function') {
|
|
606
|
+
file.text()
|
|
607
|
+
.then(t => applyJson(JSON.parse(t)))
|
|
608
|
+
.catch(fail)
|
|
609
|
+
.finally(() => { this.loading = false; });
|
|
610
|
+
} else {
|
|
611
|
+
const reader = new FileReader();
|
|
612
|
+
reader.onload = () => {
|
|
613
|
+
try { applyJson(JSON.parse(String(reader.result || ''))); }
|
|
614
|
+
catch (e) { fail(e); }
|
|
615
|
+
finally { this.loading = false; }
|
|
616
|
+
};
|
|
617
|
+
reader.onerror = () => { fail(new Error('Failed to read file')); this.loading = false; };
|
|
618
|
+
reader.readAsText(file);
|
|
619
|
+
}
|
|
620
|
+
} catch (e) {
|
|
621
|
+
this.loading = false;
|
|
622
|
+
showAlert('Failed to load draft file: ' + e.message, 'danger');
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
shouldShowField(field) {
|
|
627
|
+
// Always show field if no showIf condition
|
|
628
|
+
if (!field.showIf) {
|
|
629
|
+
return true;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const condition = field.showIf;
|
|
633
|
+
const fieldValue = this.formData[condition.field];
|
|
634
|
+
|
|
635
|
+
// Handle boolean checkbox values
|
|
636
|
+
if (typeof condition.value === 'boolean') {
|
|
637
|
+
if (condition.operator === 'equals') {
|
|
638
|
+
return fieldValue === condition.value;
|
|
639
|
+
} else if (condition.operator === 'neq') {
|
|
640
|
+
return fieldValue !== condition.value;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Handle string/number comparisons
|
|
645
|
+
if (condition.operator === 'equals') {
|
|
646
|
+
return String(fieldValue) === String(condition.value);
|
|
647
|
+
} else if (condition.operator === 'neq') {
|
|
648
|
+
return String(fieldValue) !== String(condition.value);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
return true;
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
getFieldState(fieldId) {
|
|
655
|
+
const error = this.fieldErrors[fieldId];
|
|
656
|
+
if (error) return false;
|
|
657
|
+
if (this.formData[fieldId] !== '' && this.formData[fieldId] !== null && this.formData[fieldId] !== undefined) {
|
|
658
|
+
return null; // Valid but not required
|
|
659
|
+
}
|
|
660
|
+
return null;
|
|
661
|
+
},
|
|
662
|
+
|
|
663
|
+
validateField(field) {
|
|
664
|
+
const value = this.formData[field.id];
|
|
665
|
+
let error = '';
|
|
666
|
+
|
|
667
|
+
// Required validation
|
|
668
|
+
if (field.required) {
|
|
669
|
+
if (field.type === 'keyvalue') {
|
|
670
|
+
const mode = field.keyvalueMode || 'pairs';
|
|
671
|
+
if (mode === 'delimiter') {
|
|
672
|
+
const lines = keyvalueDelimiterLines(value);
|
|
673
|
+
if (!lines || lines.length === 0) {
|
|
674
|
+
error = field.validateMessage || 'At least one line is required';
|
|
675
|
+
this.fieldErrors[field.id] = error;
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
// pairs mode: require at least one pair with a key
|
|
680
|
+
if (!value || !Array.isArray(value) || value.length === 0) {
|
|
681
|
+
error = field.validateMessage || 'At least one key-value pair is required';
|
|
682
|
+
this.fieldErrors[field.id] = error;
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
const hasEmptyKey = value.some(pair => !pair || !pair.key || !pair.key.trim());
|
|
686
|
+
if (hasEmptyKey) {
|
|
687
|
+
error = field.validateMessage || 'All pairs must have a key';
|
|
688
|
+
this.fieldErrors[field.id] = error;
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
} else if (value === '' || value === null || value === undefined ||
|
|
693
|
+
(Array.isArray(value) && value.length === 0)) {
|
|
694
|
+
error = field.validateMessage || 'This field is required';
|
|
695
|
+
this.fieldErrors[field.id] = error;
|
|
696
|
+
return false;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Skip other validations if field is empty and not required
|
|
701
|
+
if (!value && !field.required) {
|
|
702
|
+
this.fieldErrors[field.id] = '';
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Custom validators
|
|
707
|
+
if (field.validate) {
|
|
708
|
+
if (field.validate === 'email') {
|
|
709
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
710
|
+
if (!emailRegex.test(value)) {
|
|
711
|
+
error = 'Please enter a valid email address';
|
|
712
|
+
}
|
|
713
|
+
} else if (field.validate === 'phone') {
|
|
714
|
+
const phoneRegex = /^[\d\s\-\+\(\)]+$/;
|
|
715
|
+
if (!phoneRegex.test(value) || value.replace(/\D/g, '').length < 10) {
|
|
716
|
+
error = 'Please enter a valid phone number';
|
|
717
|
+
}
|
|
718
|
+
} else if (field.validate.startsWith('regex:')) {
|
|
719
|
+
const pattern = field.validate.substring(6);
|
|
720
|
+
try {
|
|
721
|
+
const regex = new RegExp(pattern);
|
|
722
|
+
if (!regex.test(value)) {
|
|
723
|
+
error = field.validateMessage || 'Invalid format';
|
|
724
|
+
}
|
|
725
|
+
} catch (e) {
|
|
726
|
+
console.warn('Invalid regex pattern:', pattern);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
this.fieldErrors[field.id] = error;
|
|
732
|
+
return error === '';
|
|
733
|
+
},
|
|
734
|
+
|
|
735
|
+
validateAll() {
|
|
736
|
+
let isValid = true;
|
|
737
|
+
this.schema.sections.forEach(section => {
|
|
738
|
+
section.fields.forEach(field => {
|
|
739
|
+
if (this.shouldShowField(field)) {
|
|
740
|
+
if (!this.validateField(field)) {
|
|
741
|
+
isValid = false;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
return isValid;
|
|
747
|
+
},
|
|
748
|
+
|
|
749
|
+
addKeyValuePair(fieldId) {
|
|
750
|
+
if (!this.formData[fieldId]) {
|
|
751
|
+
this.$set(this.formData, fieldId, []);
|
|
752
|
+
}
|
|
753
|
+
this.formData[fieldId].push({ key: '', value: '' });
|
|
754
|
+
},
|
|
755
|
+
|
|
756
|
+
removeKeyValuePair(fieldId, pairIdx) {
|
|
757
|
+
if (this.formData[fieldId] && this.formData[fieldId].length > pairIdx) {
|
|
758
|
+
this.formData[fieldId].splice(pairIdx, 1);
|
|
759
|
+
// If no pairs left and field is required, add one empty pair
|
|
760
|
+
if (this.formData[fieldId].length === 0) {
|
|
761
|
+
const field = this.schema.sections
|
|
762
|
+
.flatMap(s => s.fields)
|
|
763
|
+
.find(f => f.id === fieldId);
|
|
764
|
+
if (field && field.required) {
|
|
765
|
+
this.formData[fieldId].push({ key: '', value: '' });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
async handleSaveDraft() {
|
|
772
|
+
this.saving = true;
|
|
773
|
+
try {
|
|
774
|
+
const processed = processedFormData(this.schema, this.formData);
|
|
775
|
+
const draftData = {
|
|
776
|
+
formId: CONFIG.formId,
|
|
777
|
+
timestamp: new Date().toISOString(),
|
|
778
|
+
// raw: what the UI holds (delimiter-mode stays as textarea text)
|
|
779
|
+
data: { ...this.formData },
|
|
780
|
+
// processed: ready-to-submit (delimiter-mode converted to [{key,value},...])
|
|
781
|
+
processed: processed
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
// Always offer a downloadable draft so it works even if storage is blocked
|
|
785
|
+
const dlName = `${CONFIG.formId || 'form'}-draft-${timestampForFilename()}.json`;
|
|
786
|
+
downloadTextFile(dlName, 'application/json;charset=utf-8', JSON.stringify(draftData, null, 2));
|
|
787
|
+
|
|
788
|
+
// Save to localStorage when available
|
|
789
|
+
const storageKey = `portalsmith:draft:${CONFIG.formId}`;
|
|
790
|
+
if (canUseLocalStorage()) {
|
|
791
|
+
localStorage.setItem(storageKey, JSON.stringify(draftData));
|
|
792
|
+
showAlert('Draft saved (downloaded + stored in browser)', 'success');
|
|
793
|
+
} else {
|
|
794
|
+
showAlert('Draft downloaded (browser storage unavailable)', 'warning');
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// If storageMode is file, also send to Node-RED
|
|
798
|
+
if (CONFIG.storageMode === 'file' && uibuilderInstance) {
|
|
799
|
+
if (typeof uibuilderInstance.send === 'function') {
|
|
800
|
+
uibuilderInstance.send({
|
|
801
|
+
type: 'draft:save',
|
|
802
|
+
formId: CONFIG.formId,
|
|
803
|
+
payload: draftData
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
} catch (error) {
|
|
808
|
+
console.error('Save draft error:', error);
|
|
809
|
+
showAlert('Failed to save draft: ' + error.message, 'danger');
|
|
810
|
+
} finally {
|
|
811
|
+
this.saving = false;
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
async handleLoadDraft() {
|
|
816
|
+
this.loading = true;
|
|
817
|
+
try {
|
|
818
|
+
// Try localStorage first
|
|
819
|
+
const storageKey = `portalsmith:draft:${CONFIG.formId}`;
|
|
820
|
+
const stored = canUseLocalStorage() ? localStorage.getItem(storageKey) : null;
|
|
821
|
+
|
|
822
|
+
if (stored) {
|
|
823
|
+
const draftData = JSON.parse(stored);
|
|
824
|
+
// Use Vue reactivity-safe assignment
|
|
825
|
+
const merged = { ...this.formData, ...(draftData.data || {}) };
|
|
826
|
+
this.$set(this, 'formData', merged);
|
|
827
|
+
showAlert(`Draft loaded from browser storage (${Object.keys(draftData.data || {}).length} fields)`, 'success');
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// No browser-stored draft available. User can click "Load Draft" to choose a file.
|
|
832
|
+
showAlert('No browser draft found. Click "Load Draft" to select a draft JSON file.', 'info');
|
|
833
|
+
} catch (error) {
|
|
834
|
+
console.error('Load draft error:', error);
|
|
835
|
+
showAlert('Failed to load draft: ' + error.message, 'danger');
|
|
836
|
+
} finally {
|
|
837
|
+
this.loading = false;
|
|
838
|
+
}
|
|
839
|
+
},
|
|
840
|
+
|
|
841
|
+
loadDraftFromStorage() {
|
|
842
|
+
try {
|
|
843
|
+
const storageKey = `portalsmith:draft:${CONFIG.formId}`;
|
|
844
|
+
const stored = canUseLocalStorage() ? localStorage.getItem(storageKey) : null;
|
|
845
|
+
if (stored) {
|
|
846
|
+
const draftData = JSON.parse(stored);
|
|
847
|
+
this.formData = { ...this.formData, ...draftData.data };
|
|
848
|
+
}
|
|
849
|
+
} catch (error) {
|
|
850
|
+
console.warn('Failed to auto-load draft:', error);
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
|
|
854
|
+
async handleSubmit() {
|
|
855
|
+
if (!this.validateAll()) {
|
|
856
|
+
showAlert('Please fix validation errors before submitting', 'warning');
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
this.submitting = true;
|
|
861
|
+
try {
|
|
862
|
+
const processedData = processedFormData(this.schema, this.formData);
|
|
863
|
+
|
|
864
|
+
// Mode 1: HTTP POST directly to an API or Node-RED http in
|
|
865
|
+
if (CONFIG.submitMode === 'http') {
|
|
866
|
+
// Resolve relative URLs against current page (keeps same-origin easy)
|
|
867
|
+
const urlObj = new URL(CONFIG.submitUrl, window.location.href);
|
|
868
|
+
const url = urlObj.toString();
|
|
869
|
+
|
|
870
|
+
// Mixed-content guard: an HTTPS page cannot POST to HTTP endpoints
|
|
871
|
+
if (window.location && window.location.protocol === 'https:' && urlObj.protocol !== 'https:') {
|
|
872
|
+
throw new Error(`Insecure submit URL blocked from HTTPS page: ${url}`);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
let headers = { 'Content-Type': 'application/json' };
|
|
876
|
+
if (CONFIG.submitHeadersJson && CONFIG.submitHeadersJson.trim()) {
|
|
877
|
+
try {
|
|
878
|
+
const parsed = JSON.parse(CONFIG.submitHeadersJson);
|
|
879
|
+
headers = { ...headers, ...parsed };
|
|
880
|
+
} catch (e) {
|
|
881
|
+
// Keep defaults
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const res = await fetch(url, {
|
|
886
|
+
method: 'POST',
|
|
887
|
+
headers,
|
|
888
|
+
body: JSON.stringify({
|
|
889
|
+
formId: CONFIG.formId,
|
|
890
|
+
payload: processedData,
|
|
891
|
+
meta: {
|
|
892
|
+
timestamp: new Date().toISOString(),
|
|
893
|
+
userAgent: navigator.userAgent,
|
|
894
|
+
url: window.location.href
|
|
895
|
+
}
|
|
896
|
+
})
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
this.lastResultStatus = res.status;
|
|
900
|
+
const ct = String(res.headers.get('content-type') || '').toLowerCase();
|
|
901
|
+
let body;
|
|
902
|
+
if (ct.includes('application/json')) {
|
|
903
|
+
body = await res.json();
|
|
904
|
+
} else {
|
|
905
|
+
body = await res.text();
|
|
906
|
+
}
|
|
907
|
+
this.lastResult = body;
|
|
908
|
+
this.showResult = true;
|
|
909
|
+
|
|
910
|
+
if (!res.ok) {
|
|
911
|
+
showAlert(`HTTP submit failed (${res.status})`, 'danger');
|
|
912
|
+
} else {
|
|
913
|
+
showAlert('Submitted successfully (HTTP)', 'success');
|
|
914
|
+
}
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Mode 2: uibuilder message to Node-RED flow (default)
|
|
919
|
+
const submitData = {
|
|
920
|
+
type: 'submit',
|
|
921
|
+
formId: CONFIG.formId,
|
|
922
|
+
payload: processedData,
|
|
923
|
+
meta: {
|
|
924
|
+
timestamp: new Date().toISOString(),
|
|
925
|
+
userAgent: navigator.userAgent,
|
|
926
|
+
url: window.location.href
|
|
927
|
+
}
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
if (uibuilderInstance && typeof uibuilderInstance.send === 'function') {
|
|
931
|
+
uibuilderInstance.send(submitData);
|
|
932
|
+
showAlert('Submit sent to Node-RED (uibuilder)', 'info');
|
|
933
|
+
} else {
|
|
934
|
+
throw new Error('uibuilder not initialized');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Optionally clear form after successful submit
|
|
938
|
+
// this.resetForm();
|
|
939
|
+
|
|
940
|
+
} catch (error) {
|
|
941
|
+
console.error('Submit error:', error);
|
|
942
|
+
showAlert('Failed to submit: ' + error.message, 'danger');
|
|
943
|
+
} finally {
|
|
944
|
+
this.submitting = false;
|
|
945
|
+
}
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
async handleCopyBlock(action) {
|
|
949
|
+
try {
|
|
950
|
+
// Simple mustache-like template rendering
|
|
951
|
+
let template = action.template || '';
|
|
952
|
+
const data = this.formData;
|
|
953
|
+
|
|
954
|
+
// Replace {{fieldId}} with formData[fieldId]
|
|
955
|
+
template = template.replace(/\{\{(\w+)\}\}/g, (match, fieldId) => {
|
|
956
|
+
return data[fieldId] !== undefined ? String(data[fieldId]) : match;
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// Copy to clipboard
|
|
960
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
961
|
+
await navigator.clipboard.writeText(template);
|
|
962
|
+
showAlert(action.successMessage || 'Copied to clipboard', 'success');
|
|
963
|
+
} else {
|
|
964
|
+
// Fallback for older browsers
|
|
965
|
+
const textArea = document.createElement('textarea');
|
|
966
|
+
textArea.value = template;
|
|
967
|
+
textArea.style.position = 'fixed';
|
|
968
|
+
textArea.style.opacity = '0';
|
|
969
|
+
document.body.appendChild(textArea);
|
|
970
|
+
textArea.select();
|
|
971
|
+
document.execCommand('copy');
|
|
972
|
+
document.body.removeChild(textArea);
|
|
973
|
+
showAlert(action.successMessage || 'Copied to clipboard', 'success');
|
|
974
|
+
}
|
|
975
|
+
} catch (error) {
|
|
976
|
+
console.error('Copy block error:', error);
|
|
977
|
+
showAlert('Failed to copy: ' + error.message, 'danger');
|
|
978
|
+
}
|
|
979
|
+
},
|
|
980
|
+
|
|
981
|
+
async handleExport(format) {
|
|
982
|
+
try {
|
|
983
|
+
if (!CONFIG.exportFormats.includes(format)) {
|
|
984
|
+
showAlert(`Export format ${format} not available`, 'warning');
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Always provide a client-side export (download)
|
|
989
|
+
if (format === 'json') {
|
|
990
|
+
exportAsJson(this.schema, this.formData);
|
|
991
|
+
} else if (format === 'csv') {
|
|
992
|
+
exportAsCsv(this.schema, this.formData);
|
|
993
|
+
} else if (format === 'html') {
|
|
994
|
+
exportAsHtml(this.schema, this.formData);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Optional: also send export request to Node-RED if a flow is listening
|
|
998
|
+
try {
|
|
999
|
+
const processed = processedFormData(this.schema, this.formData);
|
|
1000
|
+
const exportData = {
|
|
1001
|
+
type: 'export',
|
|
1002
|
+
formId: CONFIG.formId,
|
|
1003
|
+
format: format,
|
|
1004
|
+
payload: processed
|
|
1005
|
+
};
|
|
1006
|
+
if (uibuilderInstance && typeof uibuilderInstance.send === 'function') {
|
|
1007
|
+
uibuilderInstance.send(exportData);
|
|
1008
|
+
}
|
|
1009
|
+
} catch (e) {
|
|
1010
|
+
// ignore
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
showAlert(`Exported (${format.toUpperCase()})`, 'success');
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
console.error('Export error:', error);
|
|
1016
|
+
showAlert('Failed to export: ' + error.message, 'danger');
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
|
|
1020
|
+
resetForm() {
|
|
1021
|
+
// Reset to defaults
|
|
1022
|
+
this.schema.sections.forEach(section => {
|
|
1023
|
+
section.fields.forEach(field => {
|
|
1024
|
+
if (field.type === 'checkbox') {
|
|
1025
|
+
this.formData[field.id] = field.defaultValue !== undefined ? field.defaultValue : false;
|
|
1026
|
+
} else if (field.type === 'number') {
|
|
1027
|
+
this.formData[field.id] = field.defaultValue !== undefined ? field.defaultValue : null;
|
|
1028
|
+
} else if (field.type === 'keyvalue') {
|
|
1029
|
+
if (field.keyvalueMode === 'delimiter') {
|
|
1030
|
+
// Delimiter mode: initialize as empty string or default value
|
|
1031
|
+
if (field.defaultValue !== undefined) {
|
|
1032
|
+
this.formData[field.id] = String(field.defaultValue);
|
|
1033
|
+
} else {
|
|
1034
|
+
// If pairs exist, convert them to delimiter format
|
|
1035
|
+
if (field.pairs && field.pairs.length > 0) {
|
|
1036
|
+
const delimiter = field.keyvalueDelimiter || '=';
|
|
1037
|
+
this.formData[field.id] = field.pairs
|
|
1038
|
+
.filter(p => p && p.key && p.key.trim())
|
|
1039
|
+
.map(p => `${p.key}${delimiter}${p.value || ''}`)
|
|
1040
|
+
.join('\n');
|
|
1041
|
+
} else {
|
|
1042
|
+
this.formData[field.id] = '';
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
} else {
|
|
1046
|
+
// Pairs mode: initialize with default pairs or empty array
|
|
1047
|
+
if (field.defaultValue && Array.isArray(field.defaultValue)) {
|
|
1048
|
+
this.formData[field.id] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
1049
|
+
} else if (field.pairs && field.pairs.length > 0) {
|
|
1050
|
+
// Use pairs from schema as initial values
|
|
1051
|
+
this.formData[field.id] = field.pairs.map(p => ({ key: p.key || '', value: p.value || '' }));
|
|
1052
|
+
} else {
|
|
1053
|
+
this.formData[field.id] = [{ key: '', value: '' }];
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
} else {
|
|
1057
|
+
this.formData[field.id] = field.defaultValue !== undefined ? field.defaultValue : '';
|
|
1058
|
+
}
|
|
1059
|
+
this.fieldErrors[field.id] = '';
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function setupUibuilderHandlers() {
|
|
1068
|
+
if (!uibuilderInstance || typeof uibuilderInstance.onChange !== 'function') return;
|
|
1069
|
+
|
|
1070
|
+
// Handle incoming messages from Node-RED (uibuilder v7.5.0 API)
|
|
1071
|
+
// onChange(prop: string, callback: (val: any) => void): number
|
|
1072
|
+
uibuilderInstance.onChange('msg', function(msg) {
|
|
1073
|
+
// Normalize message: uibuilder may send msg.payload or msg directly
|
|
1074
|
+
const payload = msg && (msg.payload ?? msg);
|
|
1075
|
+
if (!payload || typeof payload !== 'object') return;
|
|
1076
|
+
|
|
1077
|
+
// Handle draft response
|
|
1078
|
+
if (payload.type === 'draft' && payload.formId === CONFIG.formId) {
|
|
1079
|
+
if (app && payload.data) {
|
|
1080
|
+
app.formData = { ...app.formData, ...payload.data };
|
|
1081
|
+
showAlert('Draft loaded from server', 'success');
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// Handle submit response
|
|
1086
|
+
if (payload.type === 'submit:ok') {
|
|
1087
|
+
if (app) {
|
|
1088
|
+
app.lastResultStatus = payload.status || null;
|
|
1089
|
+
app.lastResult = payload.result ?? payload.payload ?? payload;
|
|
1090
|
+
app.showResult = true;
|
|
1091
|
+
app.resultView = 'table';
|
|
1092
|
+
}
|
|
1093
|
+
showAlert('Submission confirmed', 'success');
|
|
1094
|
+
} else if (payload.type === 'submit:error') {
|
|
1095
|
+
if (app) {
|
|
1096
|
+
app.lastResultStatus = payload.status || null;
|
|
1097
|
+
app.lastResult = payload.result ?? payload.error ?? payload;
|
|
1098
|
+
app.showResult = true;
|
|
1099
|
+
app.resultView = 'json';
|
|
1100
|
+
}
|
|
1101
|
+
showAlert('Submission failed', 'danger');
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Handle export response
|
|
1105
|
+
if (payload.type === 'export:file') {
|
|
1106
|
+
const fileInfo = payload.payload || payload;
|
|
1107
|
+
const format = fileInfo.format || 'file';
|
|
1108
|
+
if (fileInfo.url) {
|
|
1109
|
+
showAlert(`Export ready: <a href="${fileInfo.url}" target="_blank" rel="noopener">Download ${format.toUpperCase()}</a>`, 'success');
|
|
1110
|
+
} else if (fileInfo.path) {
|
|
1111
|
+
showAlert(`Export saved: ${fileInfo.path}`, 'success');
|
|
1112
|
+
} else {
|
|
1113
|
+
showAlert(`Export completed (${format.toUpperCase()})`, 'success');
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
function showAlert(message, variant) {
|
|
1120
|
+
if (app) {
|
|
1121
|
+
app.alertMessage = message;
|
|
1122
|
+
app.alertVariant = variant || 'info';
|
|
1123
|
+
app.showAlert = true;
|
|
1124
|
+
|
|
1125
|
+
// Auto-dismiss after 5 seconds for success/info
|
|
1126
|
+
if (variant === 'success' || variant === 'info') {
|
|
1127
|
+
setTimeout(() => {
|
|
1128
|
+
if (app) app.showAlert = false;
|
|
1129
|
+
}, 5000);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
})();
|
|
1135
|
+
|