@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,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
|
+
})();
|