@cyprnet/node-red-contrib-uibuilder-formgen 0.4.13 → 0.5.1

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.
@@ -0,0 +1,463 @@
1
+ // PortalSmith FormGen Portal Runtime (Vue 3)
2
+ // SPDX-License-Identifier: MIT
3
+ // Generated: [[timestamp]]
4
+ // Instance: [[instanceName]]
5
+ // Form ID: [[formId]]
6
+
7
+ (function() {
8
+ 'use strict';
9
+
10
+ // Configuration from runtime
11
+ const CONFIG = {
12
+ formId: '[[formId]]',
13
+ storageMode: '[[storageMode]]',
14
+ exportFormats: __EXPORT_FORMATS_JSON__,
15
+ baseUrl: '[[baseUrl]]',
16
+ themeMode: '[[themeMode]]',
17
+ license: __LICENSE_JSON__,
18
+ submitMode: '[[submitMode]]', // 'uibuilder' | 'http'
19
+ submitUrl: '[[submitUrl]]', // required when submitMode === 'http'
20
+ submitHeadersJson: '[[submitHeadersJson]]' // optional JSON string
21
+ };
22
+
23
+ function pad2(n) { return String(n).padStart(2, '0'); }
24
+ function timestampForFilename() {
25
+ const d = new Date();
26
+ return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
27
+ }
28
+
29
+ function escapeCsvCell(val) {
30
+ if (val === null || val === undefined) return '';
31
+ const s = String(val).replace(/\r?\n/g, '\\n');
32
+ if (/[,"\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
33
+ return s;
34
+ }
35
+
36
+ function downloadTextFile(filename, mime, text) {
37
+ const blob = new Blob([text], { type: mime });
38
+ const url = URL.createObjectURL(blob);
39
+ const a = document.createElement('a');
40
+ a.href = url;
41
+ a.download = filename;
42
+ document.body.appendChild(a);
43
+ a.click();
44
+ document.body.removeChild(a);
45
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
46
+ }
47
+
48
+ function canUseLocalStorage() {
49
+ try {
50
+ const k = '__ps_test__';
51
+ localStorage.setItem(k, '1');
52
+ localStorage.removeItem(k);
53
+ return true;
54
+ } catch (e) {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ function parseKeyValueDelimiter(textValue, delimiter) {
60
+ const out = [];
61
+ const text = (textValue === null || textValue === undefined) ? '' : String(textValue);
62
+ const del = delimiter || '=';
63
+ text.split('\n')
64
+ .map(l => l.trim())
65
+ .filter(Boolean)
66
+ .forEach(line => {
67
+ const parts = line.split(del);
68
+ if (parts.length >= 2) {
69
+ const key = parts[0].trim();
70
+ const value = parts.slice(1).join(del).trim();
71
+ if (key) out.push({ key, value });
72
+ }
73
+ });
74
+ return out;
75
+ }
76
+
77
+ function keyvalueDelimiterLines(textValue) {
78
+ const text = (textValue === null || textValue === undefined) ? '' : String(textValue);
79
+ return text.split('\n').map(l => l.trim()).filter(Boolean);
80
+ }
81
+
82
+ function processedFormData(schema, formData) {
83
+ const processed = Object.assign({}, formData);
84
+ (schema.sections || []).forEach(section => {
85
+ (section.fields || []).forEach(field => {
86
+ if (!field || !field.id) return;
87
+ if (field.type === 'keyvalue') {
88
+ if (field.keyvalueMode && field.keyvalueMode === 'delimiter') {
89
+ processed[field.id] = keyvalueDelimiterLines(formData[field.id]);
90
+ } else {
91
+ processed[field.id] = (formData[field.id] || []).map(p => ({ key: String(p.key || ''), value: String(p.value || '') }));
92
+ }
93
+ }
94
+ });
95
+ });
96
+ return processed;
97
+ }
98
+
99
+ function applyTheme() {
100
+ const m = String(CONFIG.themeMode || 'auto').toLowerCase();
101
+ document.documentElement.setAttribute('data-theme', m);
102
+ }
103
+
104
+ function tryParseHeadersJson(s) {
105
+ const raw = String(s || '').trim();
106
+ if (!raw) return {};
107
+ try { return JSON.parse(raw); } catch (e) { return {}; }
108
+ }
109
+
110
+ function showAlert(vm, msg, variant) {
111
+ if (!vm) return;
112
+ vm.alertMessage = String(msg || '');
113
+ vm.alertVariant = variant || 'info';
114
+ vm.showAlert = true;
115
+ }
116
+
117
+ let uibuilderInstance = null;
118
+ let app = null;
119
+
120
+ function setupUibuilderHandlers() {
121
+ if (!uibuilderInstance || !uibuilderInstance.onChange) return;
122
+ uibuilderInstance.onChange('msg', function(msg) {
123
+ const payload = msg && msg.payload;
124
+ if (!payload || typeof payload !== 'object') return;
125
+ if (!app) return;
126
+
127
+ if (payload.type === 'draft') {
128
+ app.applyDraft(payload.data || payload.payload || payload);
129
+ }
130
+ if (payload.type === 'submit:ok' || payload.type === 'submit:error') {
131
+ app.handleSubmitResponse(payload);
132
+ }
133
+ });
134
+ }
135
+
136
+ async function loadSchema() {
137
+ const response = await fetch('./form.schema.json');
138
+ if (!response.ok) throw new Error(`Failed to load schema: ${response.statusText}`);
139
+ return await response.json();
140
+ }
141
+
142
+ function initVueApp(schema) {
143
+ // Ensure ALL fields exist for reactivity and defaults
144
+ const formData = {};
145
+ const fieldErrors = {};
146
+ (schema.sections || []).forEach(section => {
147
+ (section.fields || []).forEach(field => {
148
+ if (!field || !field.id) return;
149
+ if (field.type === 'checkbox') {
150
+ formData[field.id] = Boolean(field.defaultValue);
151
+ } else if (field.type === 'number') {
152
+ formData[field.id] = (field.defaultValue !== undefined) ? field.defaultValue : null;
153
+ } else if (field.type === 'select' || field.type === 'radio') {
154
+ formData[field.id] = (field.defaultValue !== undefined) ? field.defaultValue : '';
155
+ } else if (field.type === 'keyvalue') {
156
+ if (field.keyvalueMode && field.keyvalueMode === 'delimiter') {
157
+ formData[field.id] = '';
158
+ } else {
159
+ formData[field.id] = Array.isArray(field.pairs) && field.pairs.length ? field.pairs.map(p => ({ key: String(p.key || ''), value: String(p.value || '') })) : [{ key: '', value: '' }];
160
+ }
161
+ } else {
162
+ formData[field.id] = (field.defaultValue !== undefined) ? field.defaultValue : '';
163
+ }
164
+ fieldErrors[field.id] = '';
165
+ });
166
+ });
167
+
168
+ const appConfig = {
169
+ data() {
170
+ return {
171
+ license: CONFIG.license,
172
+ schema,
173
+ formData,
174
+ fieldErrors,
175
+ showAlert: false,
176
+ alertMessage: '',
177
+ alertVariant: 'info',
178
+ saving: false,
179
+ loading: false,
180
+ submitting: false,
181
+ showResult: false,
182
+ lastResult: null,
183
+ lastResultStatus: null,
184
+ resultView: 'table',
185
+ copyBlockActions: schema.actions ? schema.actions.filter(a => a.type === 'copyBlock') : [],
186
+ exportFormats: CONFIG.exportFormats
187
+ };
188
+ },
189
+ computed: {
190
+ description() { return this.schema.description || ''; },
191
+ hasErrors() { return Object.values(this.fieldErrors).some(err => err !== ''); },
192
+ visibleFieldsBySection() {
193
+ const result = {};
194
+ (this.schema.sections || []).forEach(section => {
195
+ result[section.id] = (section.fields || []).filter(field => this.shouldShowField(field));
196
+ });
197
+ return result;
198
+ },
199
+ lastResultPretty() {
200
+ try { return JSON.stringify(this.lastResult, null, 2); } catch (e) { return String(this.lastResult || ''); }
201
+ },
202
+ resultHeaders() {
203
+ if (!this.formattedResult || !this.formattedResult.length) return [];
204
+ return Object.keys(this.formattedResult[0] || {});
205
+ },
206
+ formattedResult() {
207
+ const r = this.lastResult;
208
+ if (!r || typeof r !== 'object') return [];
209
+ // Prefer array of objects
210
+ if (Array.isArray(r) && r.length && typeof r[0] === 'object' && !Array.isArray(r[0])) {
211
+ return r.map(row => this.flattenRow(row));
212
+ }
213
+ // If object with array field
214
+ for (const k of Object.keys(r)) {
215
+ if (Array.isArray(r[k]) && r[k].length && typeof r[k][0] === 'object' && !Array.isArray(r[k][0])) {
216
+ return r[k].map(row => this.flattenRow(row));
217
+ }
218
+ }
219
+ // Single object
220
+ return [this.flattenRow(r)];
221
+ }
222
+ },
223
+ methods: {
224
+ flattenRow(obj) {
225
+ const out = {};
226
+ Object.keys(obj || {}).forEach(k => {
227
+ const v = obj[k];
228
+ if (Array.isArray(v)) {
229
+ out[k] = v.map(x => (x === null || x === undefined) ? '' : (typeof x === 'object' ? JSON.stringify(x) : String(x))).join(' | ');
230
+ } else if (v && typeof v === 'object') {
231
+ out[k] = JSON.stringify(v);
232
+ } else {
233
+ out[k] = (v === null || v === undefined) ? '' : String(v);
234
+ }
235
+ });
236
+ return out;
237
+ },
238
+ shouldShowField(field) {
239
+ if (!field || !field.showIf) return true;
240
+ const cond = field.showIf;
241
+ const val = this.formData[cond.field];
242
+ if (cond.operator === 'equals') return val === cond.value;
243
+ if (cond.operator === 'neq') return val !== cond.value;
244
+ return true;
245
+ },
246
+ validateField(field) {
247
+ if (!field || !field.id) return true;
248
+ const v = this.formData[field.id];
249
+ let err = '';
250
+ if (field.required) {
251
+ if (field.type === 'checkbox') {
252
+ if (!v) err = 'Required';
253
+ } else if (field.type === 'keyvalue' && field.keyvalueMode === 'delimiter') {
254
+ const lines = keyvalueDelimiterLines(v);
255
+ if (!lines.length) err = 'Required';
256
+ } else if (Array.isArray(v)) {
257
+ if (!v.length) err = 'Required';
258
+ } else {
259
+ if (v === null || v === undefined || String(v).trim() === '') err = 'Required';
260
+ }
261
+ }
262
+ this.fieldErrors[field.id] = err;
263
+ return !err;
264
+ },
265
+ validateAll() {
266
+ let ok = true;
267
+ (this.schema.sections || []).forEach(section => {
268
+ (section.fields || []).forEach(field => {
269
+ if (!this.shouldShowField(field)) return;
270
+ if (!this.validateField(field)) ok = false;
271
+ });
272
+ });
273
+ return ok;
274
+ },
275
+ addPair(fieldId) {
276
+ if (!Array.isArray(this.formData[fieldId])) this.formData[fieldId] = [];
277
+ this.formData[fieldId].push({ key: '', value: '' });
278
+ },
279
+ removePair(fieldId, idx) {
280
+ if (!Array.isArray(this.formData[fieldId])) return;
281
+ this.formData[fieldId].splice(idx, 1);
282
+ if (this.formData[fieldId].length === 0) this.formData[fieldId].push({ key: '', value: '' });
283
+ },
284
+ handleClearForm() {
285
+ // Reset to defaults by reloading schema initial defaults
286
+ initVueApp(this.schema);
287
+ showAlert(this, 'Form cleared', 'info');
288
+ },
289
+ handleDraftFileSelected(evt) {
290
+ const input = evt && evt.target;
291
+ const file = input && input.files && input.files[0];
292
+ if (!file) return;
293
+ input.value = '';
294
+ file.text().then(text => {
295
+ try { this.applyDraft(JSON.parse(text)); } catch (e) { showAlert(this, 'Invalid draft JSON', 'danger'); }
296
+ }).catch(() => showAlert(this, 'Failed to read draft file', 'danger'));
297
+ },
298
+ applyDraft(draft) {
299
+ if (!draft || typeof draft !== 'object') { showAlert(this, 'Invalid draft payload', 'danger'); return; }
300
+ const data = draft.data || draft.payload || draft;
301
+ Object.keys(this.formData).forEach(k => {
302
+ if (Object.prototype.hasOwnProperty.call(data, k)) this.formData[k] = data[k];
303
+ });
304
+ showAlert(this, 'Draft loaded', 'success');
305
+ },
306
+ loadDraftFromStorage() {
307
+ if (CONFIG.storageMode !== 'localstorage') return;
308
+ if (!canUseLocalStorage()) return;
309
+ const key = `ps:draft:${CONFIG.formId}`;
310
+ const txt = localStorage.getItem(key);
311
+ if (!txt) return;
312
+ try { this.applyDraft(JSON.parse(txt)); } catch (e) { /* ignore */ }
313
+ },
314
+ handleSaveDraft() {
315
+ this.saving = true;
316
+ try {
317
+ const payload = { formId: CONFIG.formId, timestamp: new Date().toISOString(), data: this.formData };
318
+ if (CONFIG.storageMode === 'localstorage' && canUseLocalStorage()) {
319
+ localStorage.setItem(`ps:draft:${CONFIG.formId}`, JSON.stringify(payload));
320
+ showAlert(this, 'Draft saved', 'success');
321
+ } else if (uibuilderInstance && uibuilderInstance.send) {
322
+ uibuilderInstance.send({ payload: { type: 'draft:save', formId: CONFIG.formId, payload } });
323
+ showAlert(this, 'Draft sent to Node-RED', 'info');
324
+ } else {
325
+ showAlert(this, 'No draft storage available', 'warning');
326
+ }
327
+ } catch (e) {
328
+ showAlert(this, 'Failed to save draft', 'danger');
329
+ } finally {
330
+ this.saving = false;
331
+ }
332
+ },
333
+ handleCopyBlock(action) {
334
+ try {
335
+ const tpl = String(action.template || '');
336
+ const replaced = tpl.replace(/\{\{(\w+)\}\}/g, (_m, fieldId) => {
337
+ const v = this.formData[fieldId];
338
+ return (v === null || v === undefined) ? '' : String(v);
339
+ });
340
+ navigator.clipboard.writeText(replaced);
341
+ showAlert(this, action.successMessage || 'Copied', 'success');
342
+ } catch (e) {
343
+ showAlert(this, 'Copy failed', 'danger');
344
+ }
345
+ },
346
+ async handleSubmit() {
347
+ this.submitting = true;
348
+ this.showAlert = false;
349
+ try {
350
+ if (!this.validateAll()) {
351
+ showAlert(this, 'Please fix validation errors', 'danger');
352
+ return;
353
+ }
354
+ const payload = processedFormData(this.schema, this.formData);
355
+ const meta = { timestamp: new Date().toISOString(), userAgent: navigator.userAgent, url: window.location.href };
356
+
357
+ if (CONFIG.submitMode === 'http') {
358
+ const headers = Object.assign({ 'Content-Type': 'application/json' }, tryParseHeadersJson(CONFIG.submitHeadersJson));
359
+ const resp = await fetch(CONFIG.submitUrl, { method: 'POST', headers, body: JSON.stringify({ formId: CONFIG.formId, payload, meta }) });
360
+ const text = await resp.text();
361
+ let json = null;
362
+ try { json = JSON.parse(text); } catch (e) { /* ignore */ }
363
+ this.lastResultStatus = resp.status;
364
+ this.lastResult = (json !== null) ? json : text;
365
+ this.showResult = true;
366
+ showAlert(this, resp.ok ? 'Submitted' : 'Submit error', resp.ok ? 'success' : 'danger');
367
+ return;
368
+ }
369
+
370
+ if (uibuilderInstance && uibuilderInstance.send) {
371
+ uibuilderInstance.send({ payload: { type: 'submit', formId: CONFIG.formId, payload, meta } });
372
+ showAlert(this, 'Submitted to Node-RED', 'success');
373
+ } else {
374
+ showAlert(this, 'uibuilder not available; submit skipped', 'warning');
375
+ }
376
+ } catch (e) {
377
+ showAlert(this, 'Submit failed: ' + (e && e.message ? e.message : e), 'danger');
378
+ } finally {
379
+ this.submitting = false;
380
+ }
381
+ },
382
+ handleSubmitResponse(payload) {
383
+ this.lastResultStatus = payload.status || null;
384
+ this.lastResult = payload.result;
385
+ this.showResult = true;
386
+ showAlert(this, payload.type === 'submit:ok' ? 'Submission OK' : 'Submission error', payload.type === 'submit:ok' ? 'success' : 'danger');
387
+ },
388
+ handleExport(format) {
389
+ const f = String(format || '').toLowerCase();
390
+ if (!f) return;
391
+ const data = processedFormData(this.schema, this.formData);
392
+ const ts = timestampForFilename();
393
+ if (f === 'json') {
394
+ downloadTextFile(`${CONFIG.formId}_${ts}.json`, 'application/json', JSON.stringify(data, null, 2));
395
+ } else if (f === 'csv') {
396
+ const keys = Object.keys(data);
397
+ const header = keys.map(escapeCsvCell).join(',');
398
+ const row = keys.map(k => escapeCsvCell(data[k])).join(',');
399
+ downloadTextFile(`${CONFIG.formId}_${ts}.csv`, 'text/csv', header + '\n' + row + '\n');
400
+ } else if (f === 'html') {
401
+ const html = `<!doctype html><html><head><meta charset="utf-8"><title>${CONFIG.formId}</title></head><body><pre>${String(JSON.stringify(data, null, 2)).replace(/[&<>]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}</pre></body></html>`;
402
+ downloadTextFile(`${CONFIG.formId}_${ts}.html`, 'text/html', html);
403
+ }
404
+ },
405
+ downloadResultJson() {
406
+ if (!this.lastResult) return;
407
+ const ts = timestampForFilename();
408
+ downloadTextFile(`${CONFIG.formId}_result_${ts}.json`, 'application/json', JSON.stringify(this.lastResult, null, 2));
409
+ }
410
+ }
411
+ };
412
+
413
+ // Vue 3 global build
414
+ if (typeof Vue === 'undefined' || typeof Vue.createApp !== 'function') {
415
+ throw new Error('Vue 3 not loaded');
416
+ }
417
+ app = Vue.createApp(appConfig).mount('#app');
418
+ }
419
+
420
+ async function init() {
421
+ try {
422
+ applyTheme();
423
+
424
+ if (CONFIG.license && CONFIG.license.brandingLocked && CONFIG.license.brandingAttempted) {
425
+ console.warn('PortalSmith FormGen: custom branding is disabled in Free mode (watermarked). Using default branding.');
426
+ }
427
+
428
+ if (typeof uibuilder === 'undefined') {
429
+ await new Promise(resolve => {
430
+ let attempts = 0;
431
+ const check = setInterval(() => {
432
+ if (typeof uibuilder !== 'undefined') {
433
+ clearInterval(check);
434
+ resolve();
435
+ } else if (++attempts > 20) {
436
+ clearInterval(check);
437
+ console.warn('uibuilder not found, some features may not work');
438
+ resolve();
439
+ }
440
+ }, 100);
441
+ });
442
+ }
443
+
444
+ if (typeof uibuilder !== 'undefined' && typeof uibuilder.start === 'function') {
445
+ uibuilder.start();
446
+ uibuilderInstance = uibuilder;
447
+ }
448
+
449
+ const schema = await loadSchema();
450
+ initVueApp(schema);
451
+ setupUibuilderHandlers();
452
+ if (app && typeof app.loadDraftFromStorage === 'function') {
453
+ app.loadDraftFromStorage();
454
+ }
455
+ } catch (e) {
456
+ console.error('Portal initialization error:', e);
457
+ }
458
+ }
459
+
460
+ document.addEventListener('DOMContentLoaded', init);
461
+ })();
462
+
463
+