@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.
Files changed (52) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +22 -0
  3. package/README.md +58 -0
  4. package/docs/user-guide.html +565 -0
  5. package/examples/formgen-builder/src/index.html +921 -0
  6. package/examples/formgen-builder/src/index.js +1338 -0
  7. package/examples/portalsmith-formgen-example.json +531 -0
  8. package/examples/schema-builder-integration.json +109 -0
  9. package/examples/schemas/Banking/banking_fraud_report.json +102 -0
  10. package/examples/schemas/Banking/banking_kyc_update.json +59 -0
  11. package/examples/schemas/Banking/banking_loan_application.json +113 -0
  12. package/examples/schemas/Banking/banking_new_account.json +98 -0
  13. package/examples/schemas/Banking/banking_wire_transfer_request.json +94 -0
  14. package/examples/schemas/HR/hr_employee_change_form.json +65 -0
  15. package/examples/schemas/HR/hr_exit_interview.json +105 -0
  16. package/examples/schemas/HR/hr_job_application.json +166 -0
  17. package/examples/schemas/HR/hr_onboarding_request.json +140 -0
  18. package/examples/schemas/HR/hr_time_off_request.json +95 -0
  19. package/examples/schemas/HR/hr_training_request.json +70 -0
  20. package/examples/schemas/Healthcare/health_appointment_request.json +103 -0
  21. package/examples/schemas/Healthcare/health_incident_report.json +82 -0
  22. package/examples/schemas/Healthcare/health_lab_order_request.json +72 -0
  23. package/examples/schemas/Healthcare/health_medication_refill.json +72 -0
  24. package/examples/schemas/Healthcare/health_patient_intake.json +113 -0
  25. package/examples/schemas/IT/it_access_request.json +145 -0
  26. package/examples/schemas/IT/it_dhcp_reservation.json +175 -0
  27. package/examples/schemas/IT/it_dns_domain_external.json +192 -0
  28. package/examples/schemas/IT/it_dns_domain_internal.json +171 -0
  29. package/examples/schemas/IT/it_network_change_request.json +126 -0
  30. package/examples/schemas/IT/it_network_request-form.json +299 -0
  31. package/examples/schemas/IT/it_new_hardware_request.json +155 -0
  32. package/examples/schemas/IT/it_password_reset.json +133 -0
  33. package/examples/schemas/IT/it_software_license_request.json +93 -0
  34. package/examples/schemas/IT/it_static_ip_request.json +199 -0
  35. package/examples/schemas/IT/it_subnet_request_form.json +216 -0
  36. package/examples/schemas/Maintenance/maint_checklist.json +176 -0
  37. package/examples/schemas/Maintenance/maint_facility_issue_report.json +127 -0
  38. package/examples/schemas/Maintenance/maint_incident_intake.json +174 -0
  39. package/examples/schemas/Maintenance/maint_inventory_restock.json +79 -0
  40. package/examples/schemas/Maintenance/maint_safety_audit.json +92 -0
  41. package/examples/schemas/Maintenance/maint_vehicle_inspection.json +112 -0
  42. package/examples/schemas/Maintenance/maint_work_order.json +134 -0
  43. package/index.js +12 -0
  44. package/lib/licensing.js +254 -0
  45. package/nodes/portalsmith-license.html +40 -0
  46. package/nodes/portalsmith-license.js +23 -0
  47. package/nodes/uibuilder-formgen.html +261 -0
  48. package/nodes/uibuilder-formgen.js +598 -0
  49. package/package.json +47 -0
  50. package/scripts/normalize_schema_titles.py +77 -0
  51. package/templates/index.html.mustache +541 -0
  52. 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, '&amp;')
221
+ .replace(/</g, '&lt;')
222
+ .replace(/>/g, '&gt;')
223
+ .replace(/"/g, '&quot;')
224
+ .replace(/'/g, '&#039;');
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
+