@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,1338 @@
1
+ // PortalSmith Schema Builder
2
+ // Visual schema editor for uibuilder-formgen
3
+
4
+ (function() {
5
+ 'use strict';
6
+
7
+ let uibuilderInstance = null;
8
+ let app = null;
9
+
10
+ // Initialize when DOM is ready
11
+ if (document.readyState === 'loading') {
12
+ document.addEventListener('DOMContentLoaded', init);
13
+ } else {
14
+ init();
15
+ }
16
+
17
+ async function init() {
18
+ try {
19
+ // Wait for uibuilder to be available
20
+ if (typeof uibuilder === 'undefined') {
21
+ await new Promise(resolve => {
22
+ const checkUibuilder = setInterval(() => {
23
+ if (typeof uibuilder !== 'undefined') {
24
+ clearInterval(checkUibuilder);
25
+ resolve();
26
+ }
27
+ }, 100);
28
+ });
29
+ }
30
+
31
+ // Start uibuilder
32
+ if (typeof uibuilder !== 'undefined' && typeof uibuilder.start === 'function') {
33
+ uibuilder.start();
34
+ uibuilderInstance = uibuilder;
35
+ }
36
+
37
+ // Initialize Vue app
38
+ initVueApp();
39
+
40
+ // Load saved schemas list (after app is initialized)
41
+ if (app) {
42
+ loadSavedSchemasList();
43
+ }
44
+
45
+ // Setup uibuilder message handlers
46
+ setupUibuilderHandlers();
47
+
48
+ } catch (error) {
49
+ console.error('Initialization error:', error);
50
+ }
51
+ }
52
+
53
+ function initVueApp() {
54
+ // Default empty schema
55
+ const defaultSchema = {
56
+ schemaVersion: '1.0',
57
+ formId: '',
58
+ title: '',
59
+ description: '',
60
+ sections: [],
61
+ actions: []
62
+ };
63
+
64
+ app = new Vue({
65
+ el: '#app',
66
+ data: {
67
+ schema: JSON.parse(JSON.stringify(defaultSchema)), // Deep copy
68
+ showAlert: false,
69
+ alertMessage: '',
70
+ alertVariant: 'info',
71
+ showPreview: false,
72
+ showHelp: false,
73
+
74
+ // Section editor
75
+ editingSection: null,
76
+ currentSection: {
77
+ id: '',
78
+ title: '',
79
+ description: ''
80
+ },
81
+
82
+ // Field editor
83
+ editingField: null,
84
+ editingFieldLocation: null, // {sectionIdx, fieldIdx}
85
+ currentField: {
86
+ id: '',
87
+ label: '',
88
+ type: 'text',
89
+ required: false,
90
+ placeholder: '',
91
+ help: '',
92
+ defaultValue: '',
93
+ inputType: 'text',
94
+ rows: 3,
95
+ min: null,
96
+ max: null,
97
+ step: null,
98
+ options: [],
99
+ validate: null,
100
+ validatePattern: '',
101
+ validateMessage: ''
102
+ },
103
+
104
+ // CSV/JSON import
105
+ csvData: '',
106
+ jsonData: '',
107
+
108
+ // Save/Load
109
+ saveName: '',
110
+ savedSchemas: [],
111
+ selectedLoadSchema: null,
112
+
113
+ // Bulk options
114
+ showBulkOptions: false,
115
+ bulkOptionsText: '',
116
+
117
+ // Field types
118
+ fieldTypes: [
119
+ { value: 'text', text: 'Text Input' },
120
+ { value: 'textarea', text: 'Textarea' },
121
+ { value: 'number', text: 'Number' },
122
+ { value: 'select', text: 'Select Dropdown' },
123
+ { value: 'radio', text: 'Radio Buttons' },
124
+ { value: 'checkbox', text: 'Checkbox' },
125
+ { value: 'date', text: 'Date' },
126
+ { value: 'keyvalue', text: 'Key-Value Pairs' }
127
+ ],
128
+
129
+ inputTypes: [
130
+ { value: 'text', text: 'Text' },
131
+ { value: 'email', text: 'Email' },
132
+ { value: 'tel', text: 'Telephone' },
133
+ { value: 'url', text: 'URL' },
134
+ { value: 'password', text: 'Password' }
135
+ ],
136
+
137
+ validationTypes: [
138
+ { value: null, text: 'None' },
139
+ { value: 'email', text: 'Email' },
140
+ { value: 'phone', text: 'Phone' },
141
+ { value: 'regex', text: 'Regex Pattern' }
142
+ ]
143
+ },
144
+
145
+ computed: {
146
+ formIdState() {
147
+ if (!this.schema.formId) return null;
148
+ return /^[a-zA-Z0-9._-]+$/.test(this.schema.formId) ? true : false;
149
+ },
150
+
151
+ fieldIdState() {
152
+ if (!this.currentField.id) return null;
153
+ return /^[a-zA-Z0-9._-]+$/.test(this.currentField.id) ? true : false;
154
+ },
155
+
156
+ canGenerate() {
157
+ return this.schema.formId &&
158
+ this.schema.title &&
159
+ this.schema.sections.length > 0 &&
160
+ this.schema.sections.some(s => s.fields.length > 0) &&
161
+ this.formIdState === true;
162
+ },
163
+
164
+ canSave() {
165
+ return this.schema.formId && this.schema.title;
166
+ },
167
+
168
+ savedSchemaOptions() {
169
+ return this.savedSchemas.map((s, idx) => ({
170
+ value: idx,
171
+ text: `${s.name} (${s.date})`
172
+ }));
173
+ },
174
+
175
+ jsonPreview() {
176
+ return JSON.stringify(this.schema, null, 2);
177
+ }
178
+ },
179
+
180
+ methods: {
181
+ isSchemaEmpty() {
182
+ const s = this.schema || {};
183
+ return (
184
+ !s.formId &&
185
+ !s.title &&
186
+ !s.description &&
187
+ (!s.sections || s.sections.length === 0) &&
188
+ (!s.actions || s.actions.length === 0)
189
+ );
190
+ },
191
+
192
+ newSchema() {
193
+ // Avoid accidental loss of work
194
+ if (!this.isSchemaEmpty()) {
195
+ const ok = confirm('Start a new schema? This will reset the current form metadata, sections, and fields (unsaved changes will be lost).');
196
+ if (!ok) return;
197
+ }
198
+
199
+ // Close any open modals
200
+ try {
201
+ this.$bvModal.hide('section-modal');
202
+ this.$bvModal.hide('field-modal');
203
+ this.$bvModal.hide('csv-modal');
204
+ this.$bvModal.hide('json-modal');
205
+ this.$bvModal.hide('save-modal');
206
+ this.$bvModal.hide('load-modal');
207
+ } catch (e) {
208
+ // ignore
209
+ }
210
+
211
+ // Reset schema and editor state
212
+ this.$set(this, 'schema', JSON.parse(JSON.stringify(defaultSchema)));
213
+ this.editingSection = null;
214
+ this.editingField = null;
215
+ this.editingFieldLocation = null;
216
+ this.currentSection = { id: '', title: '', description: '' };
217
+ this.resetCurrentField();
218
+
219
+ // Reset auxiliary UI state
220
+ this.showPreview = false;
221
+ this.showBulkOptions = false;
222
+ this.bulkOptionsText = '';
223
+ this.csvData = '';
224
+ this.jsonData = '';
225
+ this.saveName = '';
226
+ this.selectedLoadSchema = null;
227
+
228
+ this.$forceUpdate();
229
+ this.showAlertMessage('New schema started', 'info');
230
+ },
231
+
232
+ showAlertMessage(message, variant) {
233
+ this.alertMessage = message;
234
+ this.alertVariant = variant || 'info';
235
+ this.showAlert = true;
236
+ setTimeout(() => {
237
+ this.showAlert = false;
238
+ }, 5000);
239
+ },
240
+
241
+ // Section management
242
+ addSection() {
243
+ this.editingSection = null;
244
+ this.currentSection = {
245
+ id: '',
246
+ title: '',
247
+ description: ''
248
+ };
249
+ this.$bvModal.show('section-modal');
250
+ },
251
+
252
+ editSection(sectionIdx) {
253
+ this.editingSection = sectionIdx;
254
+ this.currentSection = JSON.parse(JSON.stringify(this.schema.sections[sectionIdx]));
255
+ this.$bvModal.show('section-modal');
256
+ },
257
+
258
+ saveSection() {
259
+ if (!this.currentSection.id || !this.currentSection.title) {
260
+ this.showAlertMessage('Section ID and Title are required', 'warning');
261
+ return;
262
+ }
263
+
264
+ // Validate section ID
265
+ if (!/^[a-zA-Z0-9._-]+$/.test(this.currentSection.id)) {
266
+ this.showAlertMessage('Section ID must be alphanumeric with underscores or hyphens', 'warning');
267
+ return;
268
+ }
269
+
270
+ // Check for duplicate ID
271
+ if (this.editingSection === null) {
272
+ const exists = this.schema.sections.some(s => s.id === this.currentSection.id);
273
+ if (exists) {
274
+ this.showAlertMessage('Section ID already exists', 'warning');
275
+ return;
276
+ }
277
+ // Add new section
278
+ this.schema.sections.push({
279
+ id: this.currentSection.id,
280
+ title: this.currentSection.title,
281
+ description: this.currentSection.description || '',
282
+ fields: []
283
+ });
284
+ } else {
285
+ // Update existing section
286
+ const oldId = this.schema.sections[this.editingSection].id;
287
+ this.schema.sections[this.editingSection] = {
288
+ id: this.currentSection.id,
289
+ title: this.currentSection.title,
290
+ description: this.currentSection.description || '',
291
+ fields: this.schema.sections[this.editingSection].fields
292
+ };
293
+
294
+ // If ID changed, update field references if needed
295
+ if (oldId !== this.currentSection.id) {
296
+ // Could update showIf conditions here if needed
297
+ }
298
+ }
299
+
300
+ this.$bvModal.hide('section-modal');
301
+ this.showAlertMessage('Section saved', 'success');
302
+ },
303
+
304
+ cancelEditSection() {
305
+ this.editingSection = null;
306
+ this.currentSection = { id: '', title: '', description: '' };
307
+ },
308
+
309
+ deleteSection(sectionIdx) {
310
+ if (confirm('Delete this section and all its fields?')) {
311
+ this.schema.sections.splice(sectionIdx, 1);
312
+ this.showAlertMessage('Section deleted', 'success');
313
+ }
314
+ },
315
+
316
+ // Field management
317
+ addField(sectionIdx) {
318
+ this.editingField = null;
319
+ this.editingFieldLocation = { sectionIdx, fieldIdx: null };
320
+ this.resetCurrentField();
321
+ this.$bvModal.show('field-modal');
322
+ },
323
+
324
+ editField(sectionIdx, fieldIdx) {
325
+ this.editingField = true;
326
+ this.editingFieldLocation = { sectionIdx, fieldIdx };
327
+ const field = this.schema.sections[sectionIdx].fields[fieldIdx];
328
+ this.currentField = JSON.parse(JSON.stringify(field));
329
+
330
+ // Normalize options
331
+ if (this.currentField.options && Array.isArray(this.currentField.options)) {
332
+ this.currentField.options = this.currentField.options.map(opt => {
333
+ if (typeof opt === 'string') {
334
+ return { value: opt, text: opt };
335
+ }
336
+ return opt;
337
+ });
338
+ } else if (!this.currentField.options) {
339
+ this.currentField.options = [];
340
+ }
341
+
342
+ // Ensure pairs array exists for keyvalue fields
343
+ if (this.currentField.type === 'keyvalue') {
344
+ if (!this.currentField.keyvalueMode) {
345
+ this.currentField.keyvalueMode = 'pairs';
346
+ }
347
+ if (!this.currentField.keyvalueDelimiter) {
348
+ this.currentField.keyvalueDelimiter = '=';
349
+ }
350
+ if (this.currentField.keyvalueMode === 'pairs') {
351
+ if (!this.currentField.pairs || !Array.isArray(this.currentField.pairs)) {
352
+ this.$set(this.currentField, 'pairs', [{ key: '', value: '' }]);
353
+ }
354
+ }
355
+ }
356
+
357
+ // Handle validation
358
+ if (this.currentField.validate && this.currentField.validate.startsWith('regex:')) {
359
+ this.currentField.validate = 'regex';
360
+ this.currentField.validatePattern = this.currentField.validate.substring(6);
361
+ }
362
+
363
+ this.$bvModal.show('field-modal');
364
+ },
365
+
366
+ saveField() {
367
+ if (!this.currentField.id || !this.currentField.label) {
368
+ this.showAlertMessage('Field ID and Label are required', 'warning');
369
+ return;
370
+ }
371
+
372
+ // Validate field ID
373
+ if (!/^[a-zA-Z0-9._-]+$/.test(this.currentField.id)) {
374
+ this.showAlertMessage('Field ID must be alphanumeric with underscores or hyphens', 'warning');
375
+ return;
376
+ }
377
+
378
+ const { sectionIdx, fieldIdx } = this.editingFieldLocation;
379
+ const section = this.schema.sections[sectionIdx];
380
+
381
+ // Check for duplicate ID in section
382
+ if (fieldIdx === null) {
383
+ const exists = section.fields.some(f => f.id === this.currentField.id);
384
+ if (exists) {
385
+ this.showAlertMessage('Field ID already exists in this section', 'warning');
386
+ return;
387
+ }
388
+ } else {
389
+ // Check if ID changed and conflicts
390
+ const oldField = section.fields[fieldIdx];
391
+ if (oldField.id !== this.currentField.id) {
392
+ const exists = section.fields.some((f, idx) => idx !== fieldIdx && f.id === this.currentField.id);
393
+ if (exists) {
394
+ this.showAlertMessage('Field ID already exists in this section', 'warning');
395
+ return;
396
+ }
397
+ }
398
+ }
399
+
400
+ // Build field object - start with base properties
401
+ const field = {
402
+ id: this.currentField.id,
403
+ label: this.currentField.label,
404
+ type: this.currentField.type, // Always include type
405
+ required: this.currentField.required || false
406
+ };
407
+
408
+ // Add optional common properties
409
+ if (this.currentField.placeholder) field.placeholder = this.currentField.placeholder;
410
+ if (this.currentField.help) field.help = this.currentField.help;
411
+ if (this.currentField.defaultValue !== '' && this.currentField.defaultValue !== null && this.currentField.defaultValue !== undefined) {
412
+ field.defaultValue = this.currentField.defaultValue;
413
+ }
414
+
415
+ // Type-specific properties - only add properties relevant to current type
416
+ if (this.currentField.type === 'text') {
417
+ if (this.currentField.inputType && this.currentField.inputType !== 'text') {
418
+ field.inputType = this.currentField.inputType;
419
+ }
420
+ // Remove type-specific properties from other types
421
+ delete field.rows;
422
+ delete field.min;
423
+ delete field.max;
424
+ delete field.step;
425
+ delete field.options;
426
+ } else if (this.currentField.type === 'textarea') {
427
+ if (this.currentField.rows && this.currentField.rows !== 3) {
428
+ field.rows = this.currentField.rows;
429
+ }
430
+ // Remove type-specific properties from other types
431
+ delete field.inputType;
432
+ delete field.min;
433
+ delete field.max;
434
+ delete field.step;
435
+ delete field.options;
436
+ } else if (this.currentField.type === 'number') {
437
+ if (this.currentField.min !== null && this.currentField.min !== undefined) field.min = this.currentField.min;
438
+ if (this.currentField.max !== null && this.currentField.max !== undefined) field.max = this.currentField.max;
439
+ if (this.currentField.step !== null && this.currentField.step !== undefined) field.step = this.currentField.step;
440
+ // Remove type-specific properties from other types
441
+ delete field.inputType;
442
+ delete field.rows;
443
+ delete field.options;
444
+ } else if (this.currentField.type === 'select' || this.currentField.type === 'radio') {
445
+ if (!this.currentField.options || this.currentField.options.length === 0) {
446
+ this.showAlertMessage(`${this.currentField.type} fields require at least one option`, 'warning');
447
+ return;
448
+ }
449
+ // Filter out empty options and ensure both value and text exist
450
+ const validOptions = this.currentField.options
451
+ .filter(opt => opt && (opt.value !== undefined || opt.text !== undefined))
452
+ .map(opt => ({
453
+ value: String(opt.value || opt.text || ''),
454
+ text: String(opt.text || opt.value || '')
455
+ }));
456
+
457
+ if (validOptions.length === 0) {
458
+ this.showAlertMessage(`${this.currentField.type} fields require at least one valid option`, 'warning');
459
+ return;
460
+ }
461
+
462
+ field.options = validOptions;
463
+ // Remove type-specific properties from other types
464
+ delete field.inputType;
465
+ delete field.rows;
466
+ delete field.min;
467
+ delete field.max;
468
+ delete field.step;
469
+ delete field.pairs;
470
+ } else if (this.currentField.type === 'keyvalue') {
471
+ // Set mode and delimiter
472
+ field.keyvalueMode = this.currentField.keyvalueMode || 'pairs';
473
+ field.keyvalueDelimiter = this.currentField.keyvalueDelimiter || '=';
474
+
475
+ if (field.keyvalueMode === 'pairs') {
476
+ if (!this.currentField.pairs || this.currentField.pairs.length === 0) {
477
+ this.showAlertMessage('Key-Value fields require at least one pair', 'warning');
478
+ return;
479
+ }
480
+ // Filter out empty pairs and ensure both key and value exist
481
+ const validPairs = this.currentField.pairs
482
+ .filter(pair => pair && pair.key && pair.key.trim())
483
+ .map(pair => ({
484
+ key: String(pair.key || '').trim(),
485
+ value: String(pair.value || '').trim()
486
+ }));
487
+
488
+ if (validPairs.length === 0) {
489
+ this.showAlertMessage('Key-Value fields require at least one valid pair with a key', 'warning');
490
+ return;
491
+ }
492
+
493
+ field.pairs = validPairs;
494
+ } else {
495
+ // Delimiter mode - no pairs needed, but validate delimiter
496
+ if (!field.keyvalueDelimiter || field.keyvalueDelimiter.trim() === '') {
497
+ this.showAlertMessage('Delimiter is required for delimiter mode', 'warning');
498
+ return;
499
+ }
500
+ }
501
+
502
+ // Remove type-specific properties from other types
503
+ delete field.inputType;
504
+ delete field.rows;
505
+ delete field.min;
506
+ delete field.max;
507
+ delete field.step;
508
+ delete field.options;
509
+ } else if (this.currentField.type === 'checkbox' || this.currentField.type === 'date') {
510
+ // Remove type-specific properties from other types
511
+ delete field.inputType;
512
+ delete field.rows;
513
+ delete field.min;
514
+ delete field.max;
515
+ delete field.step;
516
+ delete field.options;
517
+ delete field.pairs;
518
+ }
519
+
520
+ // Validation
521
+ if (this.currentField.validate) {
522
+ if (this.currentField.validate === 'regex' && this.currentField.validatePattern) {
523
+ field.validate = 'regex:' + this.currentField.validatePattern;
524
+ } else {
525
+ field.validate = this.currentField.validate;
526
+ }
527
+ if (this.currentField.validateMessage) {
528
+ field.validateMessage = this.currentField.validateMessage;
529
+ }
530
+ }
531
+
532
+ // Save field - use Vue.set for existing fields to ensure reactivity
533
+ if (fieldIdx === null) {
534
+ section.fields.push(field);
535
+ } else {
536
+ // Replace the entire field object using Vue.set to ensure type change is captured
537
+ this.$set(section.fields, fieldIdx, field);
538
+ }
539
+
540
+ this.$bvModal.hide('field-modal');
541
+ this.showAlertMessage('Field saved', 'success');
542
+ },
543
+
544
+ cancelEditField() {
545
+ this.editingField = null;
546
+ this.editingFieldLocation = null;
547
+ this.resetCurrentField();
548
+ },
549
+
550
+ deleteField(sectionIdx, fieldIdx) {
551
+ if (confirm('Delete this field?')) {
552
+ this.schema.sections[sectionIdx].fields.splice(fieldIdx, 1);
553
+ this.showAlertMessage('Field deleted', 'success');
554
+ }
555
+ },
556
+
557
+ resetCurrentField() {
558
+ // Use Object.assign to maintain reactivity
559
+ Object.assign(this.currentField, {
560
+ id: '',
561
+ label: '',
562
+ type: 'text',
563
+ required: false,
564
+ placeholder: '',
565
+ help: '',
566
+ defaultValue: '',
567
+ inputType: 'text',
568
+ rows: 3,
569
+ min: null,
570
+ max: null,
571
+ step: null,
572
+ options: [],
573
+ pairs: [],
574
+ keyvalueMode: 'pairs', // 'pairs' or 'delimiter'
575
+ keyvalueDelimiter: '=', // Delimiter for key=value format
576
+ validate: null,
577
+ validatePattern: '',
578
+ validateMessage: ''
579
+ });
580
+ // Ensure arrays are reactive
581
+ this.$set(this.currentField, 'options', []);
582
+ this.$set(this.currentField, 'pairs', []);
583
+ },
584
+
585
+ onFieldTypeChange() {
586
+ // Reset type-specific fields when type changes
587
+ if (this.currentField.type !== 'select' && this.currentField.type !== 'radio') {
588
+ this.currentField.options = [];
589
+ this.$set(this.currentField, 'options', []);
590
+ } else {
591
+ // If switching TO select/radio and no options exist, add one empty option
592
+ if (!this.currentField.options || this.currentField.options.length === 0) {
593
+ this.$set(this.currentField, 'options', [{ value: '', text: '' }]);
594
+ }
595
+ }
596
+ if (this.currentField.type !== 'keyvalue') {
597
+ this.currentField.pairs = [];
598
+ this.$set(this.currentField, 'pairs', []);
599
+ this.currentField.keyvalueMode = 'pairs';
600
+ this.currentField.keyvalueDelimiter = '=';
601
+ } else {
602
+ // If switching TO keyvalue, set defaults
603
+ if (!this.currentField.keyvalueMode) {
604
+ this.$set(this.currentField, 'keyvalueMode', 'pairs');
605
+ }
606
+ if (!this.currentField.keyvalueDelimiter) {
607
+ this.$set(this.currentField, 'keyvalueDelimiter', '=');
608
+ }
609
+ // If pairs mode and no pairs exist, add one empty pair
610
+ if (this.currentField.keyvalueMode === 'pairs') {
611
+ if (!this.currentField.pairs || this.currentField.pairs.length === 0) {
612
+ this.$set(this.currentField, 'pairs', [{ key: '', value: '' }]);
613
+ }
614
+ }
615
+ }
616
+ if (this.currentField.type !== 'number') {
617
+ this.currentField.min = null;
618
+ this.currentField.max = null;
619
+ this.currentField.step = null;
620
+ }
621
+ if (this.currentField.type !== 'textarea') {
622
+ this.currentField.rows = 3;
623
+ }
624
+ },
625
+
626
+ onKeyValueModeChange() {
627
+ // Ensure Vue reactivity when mode changes
628
+ this.$set(this.currentField, 'keyvalueMode', this.currentField.keyvalueMode);
629
+ this.$forceUpdate();
630
+
631
+ // If switching to pairs mode and no pairs exist, add one
632
+ if (this.currentField.keyvalueMode === 'pairs') {
633
+ if (!this.currentField.pairs || this.currentField.pairs.length === 0) {
634
+ this.$set(this.currentField, 'pairs', [{ key: '', value: '' }]);
635
+ }
636
+ }
637
+
638
+ // Ensure delimiter is set
639
+ if (this.currentField.keyvalueMode === 'delimiter' && !this.currentField.keyvalueDelimiter) {
640
+ this.$set(this.currentField, 'keyvalueDelimiter', '=');
641
+ }
642
+ },
643
+
644
+ addOption() {
645
+ // Ensure options array exists and is reactive
646
+ if (!this.currentField.options) {
647
+ this.$set(this.currentField, 'options', []);
648
+ }
649
+
650
+ // Create a new array with the added option to force Vue reactivity
651
+ const newOptions = [...this.currentField.options, { value: '', text: '' }];
652
+ this.$set(this.currentField, 'options', newOptions);
653
+
654
+ // Force update to ensure modal content refreshes
655
+ this.$forceUpdate();
656
+
657
+ // Use $nextTick to ensure DOM updates
658
+ this.$nextTick(() => {
659
+ // Scroll to the last option
660
+ const optionInputs = document.querySelectorAll('[id^="opt-value"]');
661
+ if (optionInputs.length > 0) {
662
+ const lastInput = optionInputs[optionInputs.length - 1];
663
+ lastInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
664
+ lastInput.focus();
665
+ }
666
+ });
667
+ },
668
+
669
+ removeOption(idx) {
670
+ // Allow deletion even if only one option - user can add more later
671
+ // Just warn if it's the last one
672
+ if (this.currentField.options.length === 1) {
673
+ if (!confirm('This is the last option. Select/Radio fields need at least one option. Delete anyway?')) {
674
+ return;
675
+ }
676
+ }
677
+
678
+ // Create a new array without the deleted option to ensure Vue reactivity
679
+ const newOptions = this.currentField.options.filter((opt, i) => i !== idx);
680
+
681
+ // If no options left, add one empty option
682
+ if (newOptions.length === 0) {
683
+ newOptions.push({ value: '', text: '' });
684
+ }
685
+
686
+ // Replace the entire array using $set to ensure reactivity
687
+ this.$set(this.currentField, 'options', newOptions);
688
+ this.$forceUpdate();
689
+ },
690
+
691
+ addPair() {
692
+ // Ensure pairs array exists and is reactive
693
+ if (!this.currentField.pairs) {
694
+ this.$set(this.currentField, 'pairs', []);
695
+ }
696
+ // Add new pair
697
+ this.currentField.pairs.push({ key: '', value: '' });
698
+ this.$forceUpdate();
699
+
700
+ // Use $nextTick to ensure DOM updates
701
+ this.$nextTick(() => {
702
+ // Scroll to the last pair
703
+ const pairInputs = document.querySelectorAll('[id^="pair-key"]');
704
+ if (pairInputs.length > 0) {
705
+ const lastInput = pairInputs[pairInputs.length - 1];
706
+ lastInput.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
707
+ lastInput.focus();
708
+ }
709
+ });
710
+ },
711
+
712
+ removePair(idx) {
713
+ // Allow deletion even if only one pair - user can add more later
714
+ // Just warn if it's the last one
715
+ if (this.currentField.pairs.length === 1) {
716
+ if (!confirm('This is the last pair. Key-Value fields need at least one pair. Delete anyway?')) {
717
+ return;
718
+ }
719
+ }
720
+
721
+ // Create a new array without the deleted pair to ensure Vue reactivity
722
+ const newPairs = this.currentField.pairs.filter((pair, i) => i !== idx);
723
+
724
+ // If no pairs left, add one empty pair
725
+ if (newPairs.length === 0) {
726
+ newPairs.push({ key: '', value: '' });
727
+ }
728
+
729
+ // Replace the entire array using $set to ensure reactivity
730
+ this.$set(this.currentField, 'pairs', newPairs);
731
+ this.$forceUpdate();
732
+ },
733
+
734
+ handleOptionPaste(event, optIdx, field) {
735
+ // Get pasted text
736
+ const pastedText = (event.clipboardData || window.clipboardData).getData('text');
737
+
738
+ // Check if it matches the format: value="xxx" | text="YYY"
739
+ const valueMatch = pastedText.match(/value\s*=\s*["']([^"']+)["']/i);
740
+ const textMatch = pastedText.match(/text\s*=\s*["']([^"']+)["']/i);
741
+
742
+ if (valueMatch && textMatch) {
743
+ // Prevent default paste
744
+ event.preventDefault();
745
+
746
+ // Parse and set both fields
747
+ this.currentField.options[optIdx].value = valueMatch[1];
748
+ this.currentField.options[optIdx].text = textMatch[1];
749
+
750
+ this.showAlertMessage('Option parsed and filled automatically', 'success');
751
+ }
752
+ // Otherwise, let default paste behavior happen
753
+ },
754
+
755
+ parseBulkOptions() {
756
+ if (!this.bulkOptionsText || !this.bulkOptionsText.trim()) {
757
+ this.showAlertMessage('Please enter options to parse', 'warning');
758
+ return;
759
+ }
760
+
761
+ const lines = this.bulkOptionsText.trim().split('\n');
762
+ const newOptions = [];
763
+
764
+ for (const line of lines) {
765
+ const trimmed = line.trim();
766
+ if (!trimmed) continue;
767
+
768
+ // Try to parse format: value="xxx" | text="YYY"
769
+ const valueMatch = trimmed.match(/value\s*=\s*["']([^"']+)["']/i);
770
+ const textMatch = trimmed.match(/text\s*=\s*["']([^"']+)["']/i);
771
+
772
+ if (valueMatch && textMatch) {
773
+ // Parsed format: value="xxx" | text="YYY"
774
+ newOptions.push({
775
+ value: valueMatch[1],
776
+ text: textMatch[1]
777
+ });
778
+ } else if (valueMatch) {
779
+ // Only value found, use it for both
780
+ newOptions.push({
781
+ value: valueMatch[1],
782
+ text: valueMatch[1]
783
+ });
784
+ } else if (textMatch) {
785
+ // Only text found, use it for both
786
+ newOptions.push({
787
+ value: textMatch[1],
788
+ text: textMatch[1]
789
+ });
790
+ } else {
791
+ // No format match, treat entire line as both value and text
792
+ newOptions.push({
793
+ value: trimmed,
794
+ text: trimmed
795
+ });
796
+ }
797
+ }
798
+
799
+ if (newOptions.length === 0) {
800
+ this.showAlertMessage('No valid options found. Check your format.', 'warning');
801
+ return;
802
+ }
803
+
804
+ // Add to existing options (or replace if empty)
805
+ if (this.currentField.options.length === 0 ||
806
+ (this.currentField.options.length === 1 && !this.currentField.options[0].value && !this.currentField.options[0].text)) {
807
+ this.currentField.options = newOptions;
808
+ } else {
809
+ this.currentField.options = [...this.currentField.options, ...newOptions];
810
+ }
811
+
812
+ this.bulkOptionsText = '';
813
+ this.showBulkOptions = false;
814
+ this.showAlertMessage(`Added ${newOptions.length} option(s)`, 'success');
815
+ },
816
+
817
+ // Help
818
+ showFieldHelp(type) {
819
+ // Could show specific help for different field types
820
+ this.showHelp = true;
821
+ },
822
+
823
+ // Save/Load Schema
824
+ saveSchema() {
825
+ console.log('saveSchema called, canSave:', this.canSave);
826
+ if (!this.canSave) {
827
+ this.showAlertMessage('Please set Form ID and Title before saving', 'warning');
828
+ return;
829
+ }
830
+ this.saveName = this.schema.title || this.schema.formId || '';
831
+ this.loadSavedSchemasList();
832
+ console.log('Showing save modal, current schema:', this.schema);
833
+ this.$bvModal.show('save-modal');
834
+ },
835
+
836
+ loadSavedSchemasList() {
837
+ try {
838
+ if (typeof Storage === 'undefined') {
839
+ console.warn('localStorage not available');
840
+ this.savedSchemas = [];
841
+ return;
842
+ }
843
+
844
+ const stored = localStorage.getItem('portalsmith:savedSchemas');
845
+ let saved = [];
846
+
847
+ if (stored) {
848
+ try {
849
+ saved = JSON.parse(stored);
850
+ } catch (e) {
851
+ console.error('Error parsing saved schemas:', e);
852
+ saved = [];
853
+ }
854
+ }
855
+
856
+ // Sort by timestamp (newest first)
857
+ saved.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
858
+
859
+ this.$set(this, 'savedSchemas', saved);
860
+ console.log('Loaded saved schemas list:', saved.length);
861
+ } catch (error) {
862
+ console.error('Error loading saved schemas list:', error);
863
+ this.$set(this, 'savedSchemas', []);
864
+ }
865
+ },
866
+
867
+ handleSaveOk(bvModalEvt) {
868
+ console.log('handleSaveOk called, saveName:', this.saveName, 'event:', bvModalEvt);
869
+
870
+ // Prevent modal from closing if validation fails
871
+ if (!this.saveName || !this.saveName.trim()) {
872
+ if (bvModalEvt && typeof bvModalEvt.preventDefault === 'function') {
873
+ bvModalEvt.preventDefault();
874
+ }
875
+ this.showAlertMessage('Please enter a schema name', 'warning');
876
+ return false; // Return false to prevent modal close
877
+ }
878
+
879
+ // Process save - this will close the modal itself
880
+ this.processSave();
881
+ return true; // Allow modal to close
882
+ },
883
+
884
+ processSave() {
885
+ try {
886
+ // Check localStorage availability
887
+ if (typeof Storage === 'undefined') {
888
+ this.showAlertMessage('localStorage is not available in this browser', 'danger');
889
+ return;
890
+ }
891
+
892
+ const schemaName = this.saveName.trim();
893
+ if (!schemaName) {
894
+ this.showAlertMessage('Schema name cannot be empty', 'warning');
895
+ return;
896
+ }
897
+
898
+ // Deep copy schema to avoid reference issues
899
+ const schemaCopy = JSON.parse(JSON.stringify(this.schema));
900
+
901
+ const savedSchema = {
902
+ name: schemaName,
903
+ date: new Date().toLocaleString(),
904
+ timestamp: Date.now(),
905
+ schema: schemaCopy
906
+ };
907
+
908
+ // Load existing saved schemas
909
+ let saved = [];
910
+ try {
911
+ const stored = localStorage.getItem('portalsmith:savedSchemas');
912
+ if (stored) {
913
+ saved = JSON.parse(stored);
914
+ }
915
+ } catch (e) {
916
+ console.warn('Error reading saved schemas:', e);
917
+ saved = [];
918
+ }
919
+
920
+ // Check if name already exists, update it
921
+ const existingIdx = saved.findIndex(s => s.name === savedSchema.name);
922
+ if (existingIdx >= 0) {
923
+ saved[existingIdx] = savedSchema;
924
+ } else {
925
+ saved.push(savedSchema);
926
+ }
927
+
928
+ // Save back to localStorage
929
+ try {
930
+ localStorage.setItem('portalsmith:savedSchemas', JSON.stringify(saved));
931
+ console.log('Schema saved to localStorage:', schemaName);
932
+ } catch (e) {
933
+ if (e.name === 'QuotaExceededError') {
934
+ this.showAlertMessage('Storage quota exceeded. Please delete some saved schemas.', 'danger');
935
+ return;
936
+ }
937
+ throw e;
938
+ }
939
+
940
+ // Update list
941
+ this.loadSavedSchemasList();
942
+
943
+ this.$bvModal.hide('save-modal');
944
+ this.showAlertMessage(`Schema "${schemaName}" saved successfully`, 'success');
945
+ this.saveName = '';
946
+
947
+ } catch (error) {
948
+ console.error('Save error:', error);
949
+ this.showAlertMessage('Failed to save schema: ' + error.message, 'danger');
950
+ }
951
+ },
952
+
953
+ cancelSave() {
954
+ this.saveName = '';
955
+ },
956
+
957
+ loadSchema() {
958
+ console.log('loadSchema called');
959
+ this.loadSavedSchemasList();
960
+ console.log('Saved schemas count:', this.savedSchemas.length);
961
+ if (this.savedSchemas.length === 0) {
962
+ this.showAlertMessage('No saved schemas found. Save a schema first.', 'info');
963
+ return;
964
+ }
965
+ this.selectedLoadSchema = null;
966
+ console.log('Showing load modal');
967
+ this.$bvModal.show('load-modal');
968
+ },
969
+
970
+ handleLoadOk(bvModalEvt) {
971
+ console.log('handleLoadOk called, selectedLoadSchema:', this.selectedLoadSchema);
972
+
973
+ // Prevent modal from closing if validation fails
974
+ if (this.selectedLoadSchema === null || this.selectedLoadSchema === undefined) {
975
+ if (bvModalEvt && typeof bvModalEvt.preventDefault === 'function') {
976
+ bvModalEvt.preventDefault();
977
+ }
978
+ this.showAlertMessage('Please select a schema to load', 'warning');
979
+ return false; // Return false to prevent modal close
980
+ }
981
+
982
+ // Process load - this will close the modal itself
983
+ this.processLoad();
984
+ return true; // Allow modal to close
985
+ },
986
+
987
+ processLoad() {
988
+ try {
989
+ // Check localStorage availability
990
+ if (typeof Storage === 'undefined') {
991
+ this.showAlertMessage('localStorage is not available in this browser', 'danger');
992
+ return;
993
+ }
994
+
995
+ if (this.selectedLoadSchema === null || this.selectedLoadSchema === undefined) {
996
+ this.showAlertMessage('Please select a schema to load', 'warning');
997
+ return;
998
+ }
999
+
1000
+ // Reload from localStorage to ensure we have latest data
1001
+ let saved = [];
1002
+ try {
1003
+ const stored = localStorage.getItem('portalsmith:savedSchemas');
1004
+ if (stored) {
1005
+ saved = JSON.parse(stored);
1006
+ }
1007
+ } catch (e) {
1008
+ console.error('Error reading saved schemas:', e);
1009
+ this.showAlertMessage('Failed to read saved schemas', 'danger');
1010
+ return;
1011
+ }
1012
+
1013
+ if (this.selectedLoadSchema >= saved.length) {
1014
+ this.showAlertMessage('Selected schema index is invalid', 'danger');
1015
+ return;
1016
+ }
1017
+
1018
+ const savedItem = saved[this.selectedLoadSchema];
1019
+ if (!savedItem || !savedItem.schema) {
1020
+ this.showAlertMessage('Selected schema is invalid or corrupted', 'danger');
1021
+ return;
1022
+ }
1023
+
1024
+ // Deep copy the schema
1025
+ const loadedSchema = JSON.parse(JSON.stringify(savedItem.schema));
1026
+
1027
+ // Ensure all required properties exist
1028
+ if (!loadedSchema.schemaVersion) loadedSchema.schemaVersion = '1.0';
1029
+ if (!loadedSchema.sections) loadedSchema.sections = [];
1030
+ if (!loadedSchema.actions) loadedSchema.actions = [];
1031
+
1032
+ // Replace entire schema object to ensure Vue reactivity
1033
+ this.$set(this, 'schema', {
1034
+ schemaVersion: loadedSchema.schemaVersion,
1035
+ formId: loadedSchema.formId || '',
1036
+ title: loadedSchema.title || '',
1037
+ description: loadedSchema.description || '',
1038
+ sections: loadedSchema.sections || [],
1039
+ actions: loadedSchema.actions || []
1040
+ });
1041
+
1042
+ // Force update to ensure UI refreshes
1043
+ this.$forceUpdate();
1044
+
1045
+ this.$bvModal.hide('load-modal');
1046
+ this.showAlertMessage(`Schema "${savedItem.name}" loaded successfully`, 'success');
1047
+ this.selectedLoadSchema = null;
1048
+
1049
+ } catch (error) {
1050
+ console.error('Load error:', error);
1051
+ this.showAlertMessage('Failed to load schema: ' + error.message, 'danger');
1052
+ }
1053
+ },
1054
+
1055
+ cancelLoad() {
1056
+ this.selectedLoadSchema = null;
1057
+ },
1058
+
1059
+ deleteSavedSchema(idx) {
1060
+ if (confirm('Delete this saved schema?')) {
1061
+ try {
1062
+ const saved = JSON.parse(localStorage.getItem('portalsmith:savedSchemas') || '[]');
1063
+ saved.splice(idx, 1);
1064
+ localStorage.setItem('portalsmith:savedSchemas', JSON.stringify(saved));
1065
+ this.loadSavedSchemasList();
1066
+ this.showAlertMessage('Schema deleted', 'success');
1067
+ } catch (error) {
1068
+ console.error('Delete error:', error);
1069
+ this.showAlertMessage('Failed to delete schema: ' + error.message, 'danger');
1070
+ }
1071
+ }
1072
+ },
1073
+
1074
+ // CSV Import
1075
+ importCSV() {
1076
+ this.csvData = '';
1077
+ this.$bvModal.show('csv-modal');
1078
+ },
1079
+
1080
+ processCSV() {
1081
+ try {
1082
+ const lines = this.csvData.trim().split('\n');
1083
+ if (lines.length < 2) {
1084
+ this.showAlertMessage('CSV must have at least a header row and one data row', 'warning');
1085
+ return;
1086
+ }
1087
+
1088
+ // Parse header
1089
+ const headers = lines[0].split(',').map(h => h.trim());
1090
+ const idIdx = headers.indexOf('id');
1091
+ const labelIdx = headers.indexOf('label');
1092
+ const typeIdx = headers.indexOf('type');
1093
+
1094
+ if (idIdx === -1 || labelIdx === -1 || typeIdx === -1) {
1095
+ this.showAlertMessage('CSV must have id, label, and type columns', 'warning');
1096
+ return;
1097
+ }
1098
+
1099
+ const requiredIdx = headers.indexOf('required');
1100
+ const sectionIdx = headers.indexOf('section');
1101
+ const placeholderIdx = headers.indexOf('placeholder');
1102
+ const helpIdx = headers.indexOf('help');
1103
+ const validateIdx = headers.indexOf('validate');
1104
+
1105
+ // Group fields by section
1106
+ const sectionMap = {};
1107
+
1108
+ for (let i = 1; i < lines.length; i++) {
1109
+ const values = lines[i].split(',').map(v => v.trim());
1110
+ if (values.length < headers.length) continue;
1111
+
1112
+ const sectionName = sectionIdx >= 0 ? (values[sectionIdx] || 'default') : 'default';
1113
+ if (!sectionMap[sectionName]) {
1114
+ sectionMap[sectionName] = {
1115
+ id: sectionName.toLowerCase().replace(/\s+/g, '_'),
1116
+ title: sectionName === 'default' ? 'Default Section' : sectionName,
1117
+ description: '',
1118
+ fields: []
1119
+ };
1120
+ }
1121
+
1122
+ const field = {
1123
+ id: values[idIdx],
1124
+ label: values[labelIdx],
1125
+ type: values[typeIdx]
1126
+ };
1127
+
1128
+ if (requiredIdx >= 0) {
1129
+ field.required = values[requiredIdx].toLowerCase() === 'true' || values[requiredIdx] === '1';
1130
+ }
1131
+
1132
+ if (placeholderIdx >= 0 && values[placeholderIdx]) {
1133
+ field.placeholder = values[placeholderIdx];
1134
+ }
1135
+
1136
+ if (helpIdx >= 0 && values[helpIdx]) {
1137
+ field.help = values[helpIdx];
1138
+ }
1139
+
1140
+ if (validateIdx >= 0 && values[validateIdx]) {
1141
+ field.validate = values[validateIdx];
1142
+ }
1143
+
1144
+ sectionMap[sectionName].fields.push(field);
1145
+ }
1146
+
1147
+ // Add sections to schema
1148
+ this.schema.sections = Object.values(sectionMap);
1149
+
1150
+ // Set form ID and title if not set
1151
+ if (!this.schema.formId) {
1152
+ this.schema.formId = 'imported_form';
1153
+ }
1154
+ if (!this.schema.title) {
1155
+ this.schema.title = 'Imported Form';
1156
+ }
1157
+
1158
+ this.$bvModal.hide('csv-modal');
1159
+ this.showAlertMessage(`Imported ${this.schema.sections.reduce((sum, s) => sum + s.fields.length, 0)} fields in ${this.schema.sections.length} sections`, 'success');
1160
+
1161
+ } catch (error) {
1162
+ console.error('CSV import error:', error);
1163
+ this.showAlertMessage('CSV import failed: ' + error.message, 'danger');
1164
+ }
1165
+ },
1166
+
1167
+ cancelCSV() {
1168
+ this.csvData = '';
1169
+ },
1170
+
1171
+ // JSON Import
1172
+ importJSON() {
1173
+ this.jsonData = JSON.stringify(this.schema, null, 2);
1174
+ this.$bvModal.show('json-modal');
1175
+ },
1176
+
1177
+ processJSON() {
1178
+ try {
1179
+ const imported = JSON.parse(this.jsonData);
1180
+
1181
+ // Validate basic structure
1182
+ if (!imported.schemaVersion || !imported.formId) {
1183
+ this.showAlertMessage('Invalid schema: missing schemaVersion or formId', 'warning');
1184
+ return;
1185
+ }
1186
+
1187
+ if (!imported.sections || !Array.isArray(imported.sections)) {
1188
+ this.showAlertMessage('Invalid schema: missing sections array', 'warning');
1189
+ return;
1190
+ }
1191
+
1192
+ // Replace current schema
1193
+ this.schema = imported;
1194
+
1195
+ // Ensure actions array exists
1196
+ if (!this.schema.actions) {
1197
+ this.schema.actions = [];
1198
+ }
1199
+
1200
+ this.$bvModal.hide('json-modal');
1201
+ this.showAlertMessage('Schema imported successfully', 'success');
1202
+
1203
+ } catch (error) {
1204
+ console.error('JSON import error:', error);
1205
+ this.showAlertMessage('JSON import failed: ' + error.message, 'danger');
1206
+ }
1207
+ },
1208
+
1209
+ cancelJSON() {
1210
+ this.jsonData = '';
1211
+ },
1212
+
1213
+ // Export JSON
1214
+ exportJSON() {
1215
+ const json = JSON.stringify(this.schema, null, 2);
1216
+ const blob = new Blob([json], { type: 'application/json' });
1217
+ const url = URL.createObjectURL(blob);
1218
+ const a = document.createElement('a');
1219
+ a.href = url;
1220
+ a.download = `${this.schema.formId || 'schema'}.json`;
1221
+ document.body.appendChild(a);
1222
+ a.click();
1223
+ document.body.removeChild(a);
1224
+ URL.revokeObjectURL(url);
1225
+ this.showAlertMessage('Schema exported', 'success');
1226
+ },
1227
+
1228
+ // Generate Form
1229
+ async generateForm() {
1230
+ if (!this.canGenerate) {
1231
+ this.showAlertMessage('Please complete form metadata and add at least one section with fields', 'warning');
1232
+ return;
1233
+ }
1234
+
1235
+ // Validate schema
1236
+ try {
1237
+ this.validateSchema();
1238
+ } catch (error) {
1239
+ this.showAlertMessage('Schema validation failed: ' + error.message, 'danger');
1240
+ return;
1241
+ }
1242
+
1243
+ // Send schema to Node-RED via uibuilder
1244
+ if (uibuilderInstance && typeof uibuilderInstance.send === 'function') {
1245
+ uibuilderInstance.send({
1246
+ type: 'generate',
1247
+ schema: this.schema
1248
+ });
1249
+ this.showAlertMessage('Schema sent to form generator. Check Node-RED flow for output.', 'info');
1250
+ } else {
1251
+ // Fallback: copy to clipboard
1252
+ const json = JSON.stringify(this.schema, null, 2);
1253
+ if (navigator.clipboard) {
1254
+ await navigator.clipboard.writeText(json);
1255
+ this.showAlertMessage('Schema copied to clipboard. Paste into formgen node.', 'info');
1256
+ } else {
1257
+ this.showAlertMessage('uibuilder not connected. Export JSON and use it manually.', 'warning');
1258
+ }
1259
+ }
1260
+ },
1261
+
1262
+ validateSchema() {
1263
+ if (!this.schema.formId) {
1264
+ throw new Error('Form ID is required');
1265
+ }
1266
+ if (!this.schema.title) {
1267
+ throw new Error('Form title is required');
1268
+ }
1269
+ if (!this.schema.sections || this.schema.sections.length === 0) {
1270
+ throw new Error('At least one section is required');
1271
+ }
1272
+
1273
+ this.schema.sections.forEach((section, sIdx) => {
1274
+ if (!section.id) {
1275
+ throw new Error(`Section ${sIdx} missing ID`);
1276
+ }
1277
+ if (!section.fields || section.fields.length === 0) {
1278
+ throw new Error(`Section ${sIdx} (${section.id}) has no fields`);
1279
+ }
1280
+
1281
+ section.fields.forEach((field, fIdx) => {
1282
+ if (!field.id) {
1283
+ throw new Error(`Section ${sIdx}, field ${fIdx} missing ID`);
1284
+ }
1285
+ if (!field.type) {
1286
+ throw new Error(`Section ${sIdx}, field ${field.id} missing type`);
1287
+ }
1288
+
1289
+ const validTypes = ['text', 'textarea', 'number', 'select', 'radio', 'checkbox', 'date', 'keyvalue'];
1290
+ if (!validTypes.includes(field.type)) {
1291
+ throw new Error(`Section ${sIdx}, field ${field.id} has invalid type: ${field.type}`);
1292
+ }
1293
+
1294
+ if (field.type === 'keyvalue') {
1295
+ const mode = field.keyvalueMode || 'pairs';
1296
+ if (mode === 'pairs' && (!field.pairs || !Array.isArray(field.pairs) || field.pairs.length === 0)) {
1297
+ throw new Error(`Section ${sIdx}, field ${field.id} (keyvalue pairs mode) requires at least one key-value pair`);
1298
+ }
1299
+ if (mode === 'delimiter' && (!field.keyvalueDelimiter || field.keyvalueDelimiter.trim() === '')) {
1300
+ throw new Error(`Section ${sIdx}, field ${field.id} (keyvalue delimiter mode) requires a delimiter`);
1301
+ }
1302
+ }
1303
+
1304
+ if ((field.type === 'select' || field.type === 'radio') &&
1305
+ (!field.options || field.options.length === 0)) {
1306
+ throw new Error(`Section ${sIdx}, field ${field.id} (${field.type}) requires options`);
1307
+ }
1308
+ });
1309
+ });
1310
+ }
1311
+ }
1312
+ });
1313
+ }
1314
+
1315
+ function setupUibuilderHandlers() {
1316
+ if (!uibuilderInstance || typeof uibuilderInstance.onChange !== 'function') return;
1317
+
1318
+ uibuilderInstance.onChange('msg', function(msg) {
1319
+ const payload = msg && (msg.payload ?? msg);
1320
+ if (!payload || typeof payload !== 'object') return;
1321
+
1322
+ // Handle responses from formgen node
1323
+ if (payload.type === 'generated' && app) {
1324
+ app.showAlertMessage(`Form generated successfully! URL: <a href="${payload.url}" target="_blank">${payload.url}</a>`, 'success');
1325
+ } else if (payload.type === 'error' && app) {
1326
+ app.showAlertMessage('Generation failed: ' + (payload.message || 'Unknown error'), 'danger');
1327
+ }
1328
+ });
1329
+ }
1330
+
1331
+ // Helper function to load saved schemas list (called from outside Vue instance)
1332
+ function loadSavedSchemasList() {
1333
+ if (app && typeof app.loadSavedSchemasList === 'function') {
1334
+ app.loadSavedSchemasList();
1335
+ }
1336
+ }
1337
+
1338
+ })();