@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,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PortalSmith FormGen - uibuilder-formgen node
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*
|
|
5
|
+
* Copyright (c) 2026 CyprNet Solutions, LLC
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
module.exports = function(RED) {
|
|
9
|
+
const path = require("path");
|
|
10
|
+
const fs = require("fs-extra");
|
|
11
|
+
const Mustache = require("mustache");
|
|
12
|
+
const crypto = require("crypto");
|
|
13
|
+
const http = require("http");
|
|
14
|
+
const https = require("https");
|
|
15
|
+
const licensing = require("../lib/licensing");
|
|
16
|
+
|
|
17
|
+
// Register admin endpoints once (used by the editor UI to show license status)
|
|
18
|
+
let _adminRegistered = false;
|
|
19
|
+
function registerAdminEndpoints() {
|
|
20
|
+
if (_adminRegistered) return;
|
|
21
|
+
_adminRegistered = true;
|
|
22
|
+
if (!RED.httpAdmin) return;
|
|
23
|
+
|
|
24
|
+
const guard =
|
|
25
|
+
RED.auth && typeof RED.auth.needsPermission === "function"
|
|
26
|
+
? RED.auth.needsPermission("uibuilder-formgen.read")
|
|
27
|
+
: function (_req, _res, next) { next(); };
|
|
28
|
+
|
|
29
|
+
RED.httpAdmin.post("/portalsmith/license/validate", guard, function (req, res) {
|
|
30
|
+
try {
|
|
31
|
+
const body = req.body || {};
|
|
32
|
+
const licenseKey = String(body.licenseKey || "").trim();
|
|
33
|
+
const licenseConfigId = String(body.licenseConfigId || "").trim();
|
|
34
|
+
|
|
35
|
+
let resolvedKey = licenseKey;
|
|
36
|
+
if (!resolvedKey && licenseConfigId) {
|
|
37
|
+
const cfg = RED.nodes.getNode(licenseConfigId);
|
|
38
|
+
resolvedKey = String(cfg && cfg.credentials ? cfg.credentials.licenseKey : "").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const result = licensing.resolveLicenseString(resolvedKey);
|
|
42
|
+
const verifier = licensing.getVerifierPublicKey();
|
|
43
|
+
res.json({
|
|
44
|
+
ok: true,
|
|
45
|
+
licensed: result.licensed,
|
|
46
|
+
tier: result.tier,
|
|
47
|
+
reason: result.reason,
|
|
48
|
+
displayName: result.display && result.display.displayName ? result.display.displayName : "",
|
|
49
|
+
licensedTo: result.display && result.display.licensedTo ? result.display.licensedTo : "",
|
|
50
|
+
expiresAt: result.display && result.display.expiresAt ? result.display.expiresAt : "",
|
|
51
|
+
features: result.features || {},
|
|
52
|
+
policy: result.policy || {},
|
|
53
|
+
verifier: {
|
|
54
|
+
source: verifier.source,
|
|
55
|
+
fingerprint: licensing.publicKeyFingerprint(verifier.pem),
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
} catch (e) {
|
|
59
|
+
res.status(500).json({ ok: false, error: String(e && e.message ? e.message : e) });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
registerAdminEndpoints();
|
|
64
|
+
|
|
65
|
+
function isNonEmptyString(v) {
|
|
66
|
+
return typeof v === "string" && v.trim().length > 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safeBasename(filename) {
|
|
70
|
+
// Basic hardening: strip path separators
|
|
71
|
+
return path.basename(String(filename || "").replace(/[/\\]+/g, "_"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function maybePrepareLogo({ logoPathRaw, logoAltRaw, srcDir }) {
|
|
75
|
+
const logoPath = String(logoPathRaw ?? "").trim();
|
|
76
|
+
const logoAlt = String(logoAltRaw ?? "Logo").trim() || "Logo";
|
|
77
|
+
|
|
78
|
+
if (!isNonEmptyString(logoPath)) {
|
|
79
|
+
return { logoUrl: "", logoAlt: logoAlt };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// If absolute path, copy it into src/images and reference it as a relative URL
|
|
83
|
+
if (path.isAbsolute(logoPath)) {
|
|
84
|
+
const exists = await fs.pathExists(logoPath);
|
|
85
|
+
if (!exists) {
|
|
86
|
+
throw new Error(`logoPath does not exist: ${logoPath}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const imagesDir = path.join(srcDir, "images");
|
|
90
|
+
await fs.ensureDir(imagesDir);
|
|
91
|
+
|
|
92
|
+
const srcBase = safeBasename(logoPath);
|
|
93
|
+
// Avoid collisions if user generates multiple times with different files of same name
|
|
94
|
+
const hash = crypto.createHash("sha1").update(logoPath).digest("hex").slice(0, 8);
|
|
95
|
+
const outName = `${hash}-${srcBase}`;
|
|
96
|
+
const outPath = path.join(imagesDir, outName);
|
|
97
|
+
|
|
98
|
+
await fs.copy(logoPath, outPath, { overwrite: true, errorOnExist: false });
|
|
99
|
+
return { logoUrl: `./images/${outName}`, logoAlt };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Non-absolute: assume it's a filename under src/images/
|
|
103
|
+
const trimmed = logoPath.replace(/^\.?\/*/, ""); // remove leading ./ or /
|
|
104
|
+
const fileOnly = safeBasename(trimmed);
|
|
105
|
+
return { logoUrl: `./images/${fileOnly}`, logoAlt };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function httpRequestJson({ url, method, headers, body, insecureTls, timeoutMs }) {
|
|
109
|
+
const urlObj = new URL(url);
|
|
110
|
+
const isHttps = urlObj.protocol === "https:";
|
|
111
|
+
const lib = isHttps ? https : http;
|
|
112
|
+
|
|
113
|
+
const reqHeaders = Object.assign({}, headers || {});
|
|
114
|
+
let bodyStr = "";
|
|
115
|
+
if (body !== undefined) {
|
|
116
|
+
bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
|
117
|
+
if (!reqHeaders["Content-Type"] && !reqHeaders["content-type"]) {
|
|
118
|
+
reqHeaders["Content-Type"] = "application/json";
|
|
119
|
+
}
|
|
120
|
+
reqHeaders["Content-Length"] = Buffer.byteLength(bodyStr);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const agent = isHttps
|
|
124
|
+
? new https.Agent({ rejectUnauthorized: insecureTls ? false : true })
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
const options = {
|
|
128
|
+
method: method || "POST",
|
|
129
|
+
protocol: urlObj.protocol,
|
|
130
|
+
hostname: urlObj.hostname,
|
|
131
|
+
port: urlObj.port || (isHttps ? 443 : 80),
|
|
132
|
+
path: urlObj.pathname + urlObj.search,
|
|
133
|
+
headers: reqHeaders,
|
|
134
|
+
agent,
|
|
135
|
+
timeout: Number(timeoutMs || 15000),
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return await new Promise((resolve, reject) => {
|
|
139
|
+
const req = lib.request(options, (res) => {
|
|
140
|
+
const chunks = [];
|
|
141
|
+
res.on("data", (d) => chunks.push(d));
|
|
142
|
+
res.on("end", () => {
|
|
143
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
144
|
+
const ct = String(res.headers["content-type"] || "").toLowerCase();
|
|
145
|
+
let json = null;
|
|
146
|
+
if (ct.includes("application/json")) {
|
|
147
|
+
try { json = JSON.parse(text); } catch (e) { /* ignore */ }
|
|
148
|
+
}
|
|
149
|
+
resolve({
|
|
150
|
+
status: res.statusCode || 0,
|
|
151
|
+
headers: res.headers,
|
|
152
|
+
text,
|
|
153
|
+
json,
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
req.on("timeout", () => {
|
|
159
|
+
req.destroy(new Error("Request timeout"));
|
|
160
|
+
});
|
|
161
|
+
req.on("error", reject);
|
|
162
|
+
|
|
163
|
+
if (bodyStr) req.write(bodyStr);
|
|
164
|
+
req.end();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function resolveUibuilderPaths({ RED, config, msg, instanceName }) {
|
|
169
|
+
const userDir = RED.settings.userDir || path.join(process.env.HOME || "", ".node-red");
|
|
170
|
+
|
|
171
|
+
// Optional overrides (msg wins over config)
|
|
172
|
+
const projectName = String(msg.options?.projectName ?? config.projectName ?? "").trim();
|
|
173
|
+
const uibRootDir = String(msg.options?.uibRootDir ?? msg.options?.uibRoot ?? config.uibRootDir ?? "").trim();
|
|
174
|
+
const instanceRootDir = String(msg.options?.instanceRootDir ?? config.instanceRootDir ?? "").trim();
|
|
175
|
+
|
|
176
|
+
// Precedence:
|
|
177
|
+
// 1) instanceRootDir (full path to instance root)
|
|
178
|
+
// 2) uibRootDir (full path to uibuilder root) + instanceName
|
|
179
|
+
// 3) projectName (derive: userDir/projects/<projectName>/uibuilder) + instanceName
|
|
180
|
+
// 4) default: userDir/uibuilder + instanceName
|
|
181
|
+
|
|
182
|
+
if (isNonEmptyString(instanceRootDir)) {
|
|
183
|
+
if (!path.isAbsolute(instanceRootDir)) {
|
|
184
|
+
throw new Error(`instanceRootDir must be an absolute path. Got: ${instanceRootDir}`);
|
|
185
|
+
}
|
|
186
|
+
const instRoot = instanceRootDir;
|
|
187
|
+
return {
|
|
188
|
+
userDir,
|
|
189
|
+
uibRoot: path.dirname(instRoot),
|
|
190
|
+
instRoot,
|
|
191
|
+
srcDir: path.join(instRoot, "src"),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let uibRoot;
|
|
196
|
+
if (isNonEmptyString(uibRootDir)) {
|
|
197
|
+
if (!path.isAbsolute(uibRootDir)) {
|
|
198
|
+
throw new Error(`uibRootDir must be an absolute path. Got: ${uibRootDir}`);
|
|
199
|
+
}
|
|
200
|
+
uibRoot = uibRootDir;
|
|
201
|
+
} else if (isNonEmptyString(projectName)) {
|
|
202
|
+
// Common Node-RED projects layout
|
|
203
|
+
uibRoot = path.join(userDir, "projects", projectName, "uibuilder");
|
|
204
|
+
} else {
|
|
205
|
+
uibRoot = path.join(userDir, "uibuilder");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const instRoot = path.join(uibRoot, instanceName);
|
|
209
|
+
return { userDir, uibRoot, instRoot, srcDir: path.join(instRoot, "src") };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function UibuilderFormGenNode(config) {
|
|
213
|
+
RED.nodes.createNode(this, config);
|
|
214
|
+
const node = this;
|
|
215
|
+
const licenseConfigNode = config.licenseConfig ? RED.nodes.getNode(config.licenseConfig) : null;
|
|
216
|
+
|
|
217
|
+
node.on("input", async function(msg, send, done) {
|
|
218
|
+
try {
|
|
219
|
+
// Handle runtime submit proxy (messages coming back from uibuilder)
|
|
220
|
+
const payload = msg && (msg.payload ?? msg);
|
|
221
|
+
if (payload && typeof payload === "object" && payload.type === "submit") {
|
|
222
|
+
const formId = payload.formId || "";
|
|
223
|
+
const submitPayload = payload.payload ?? payload.data ?? payload;
|
|
224
|
+
|
|
225
|
+
// Determine API config (msg.options overrides node config)
|
|
226
|
+
const apiUrl = String(msg.options?.apiUrl ?? config.apiUrl ?? "").trim();
|
|
227
|
+
const apiMethod = String(msg.options?.apiMethod ?? config.apiMethod ?? "POST").trim().toUpperCase();
|
|
228
|
+
const apiHeadersJson = String(msg.options?.apiHeadersJson ?? config.apiHeadersJson ?? "").trim();
|
|
229
|
+
const apiInsecureTls = (msg.options?.apiInsecureTls ?? config.apiInsecureTls) === true;
|
|
230
|
+
const apiTimeoutMs = Number(msg.options?.apiTimeoutMs ?? config.apiTimeoutMs ?? 15000);
|
|
231
|
+
|
|
232
|
+
let apiHeaders = {};
|
|
233
|
+
if (isNonEmptyString(apiHeadersJson)) {
|
|
234
|
+
try { apiHeaders = JSON.parse(apiHeadersJson); } catch (e) { throw new Error("apiHeadersJson must be valid JSON"); }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// If no apiUrl configured, just pass-through submit (so flows can handle it)
|
|
238
|
+
if (!isNonEmptyString(apiUrl)) {
|
|
239
|
+
msg.payload = {
|
|
240
|
+
type: "submit:ok",
|
|
241
|
+
formId,
|
|
242
|
+
result: { passthrough: true, payload: submitPayload },
|
|
243
|
+
status: 0,
|
|
244
|
+
};
|
|
245
|
+
send(msg);
|
|
246
|
+
done();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const apiBody = {
|
|
251
|
+
formId,
|
|
252
|
+
payload: submitPayload,
|
|
253
|
+
meta: payload.meta || {},
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const resp = await httpRequestJson({
|
|
257
|
+
url: apiUrl,
|
|
258
|
+
method: apiMethod,
|
|
259
|
+
headers: apiHeaders,
|
|
260
|
+
body: apiBody,
|
|
261
|
+
insecureTls: apiInsecureTls,
|
|
262
|
+
timeoutMs: apiTimeoutMs,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
msg.payload = {
|
|
266
|
+
type: resp.status >= 200 && resp.status < 300 ? "submit:ok" : "submit:error",
|
|
267
|
+
formId,
|
|
268
|
+
status: resp.status,
|
|
269
|
+
result: resp.json !== null ? resp.json : resp.text,
|
|
270
|
+
responseHeaders: resp.headers,
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
send(msg);
|
|
274
|
+
done();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Get schema from msg.schema (preferred) or msg.payload (fallback)
|
|
279
|
+
let schema = msg.schema;
|
|
280
|
+
if (!schema || typeof schema !== "object") {
|
|
281
|
+
schema = msg.payload;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// If schema is a string, try to parse it as JSON
|
|
285
|
+
if (typeof schema === "string") {
|
|
286
|
+
try {
|
|
287
|
+
schema = JSON.parse(schema);
|
|
288
|
+
} catch (e) {
|
|
289
|
+
throw new Error("msg.schema or msg.payload must be a JSON object or valid JSON string");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!schema || typeof schema !== "object") {
|
|
294
|
+
throw new Error("Missing required msg.schema or msg.payload object");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Validate schema structure
|
|
298
|
+
validateSchema(schema);
|
|
299
|
+
|
|
300
|
+
// Get instanceName from msg.uibuilder (preferred) or config
|
|
301
|
+
const instanceName = String(
|
|
302
|
+
msg.uibuilder ??
|
|
303
|
+
msg.options?.instance ??
|
|
304
|
+
config.instanceName ??
|
|
305
|
+
"formgen"
|
|
306
|
+
).trim();
|
|
307
|
+
|
|
308
|
+
// Validate instanceName pattern
|
|
309
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(instanceName)) {
|
|
310
|
+
throw new Error("Invalid instanceName; use letters/numbers/._- only");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Get options
|
|
314
|
+
const overwrite = (msg.options?.overwrite ?? config.overwrite) !== false;
|
|
315
|
+
const storageMode = msg.options?.storageMode ?? config.storageMode ?? "file";
|
|
316
|
+
const exportFormats = msg.options?.exportFormats ??
|
|
317
|
+
(config.exportFormats ? JSON.parse(config.exportFormats) : ["json", "csv", "html"]);
|
|
318
|
+
const uiTitle = msg.options?.uiTitle ?? schema.title ?? "PortalSmith Form";
|
|
319
|
+
const themeMode = String(msg.options?.themeMode ?? config.themeMode ?? "auto").trim().toLowerCase();
|
|
320
|
+
|
|
321
|
+
// Validate themeMode
|
|
322
|
+
if (!["auto", "light", "dark"].includes(themeMode)) {
|
|
323
|
+
throw new Error("themeMode must be 'auto', 'light', or 'dark'");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Validate storageMode
|
|
327
|
+
if (!["file", "localstorage"].includes(storageMode)) {
|
|
328
|
+
throw new Error("storageMode must be 'file' or 'localstorage'");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Validate exportFormats
|
|
332
|
+
const validFormats = ["json", "csv", "html"];
|
|
333
|
+
const invalidFormats = exportFormats.filter(f => !validFormats.includes(f));
|
|
334
|
+
if (invalidFormats.length > 0) {
|
|
335
|
+
throw new Error(`Invalid exportFormats: ${invalidFormats.join(", ")}`);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Resolve output folders (supports Node-RED Projects and explicit path overrides)
|
|
339
|
+
const { userDir, uibRoot, instRoot, srcDir } = resolveUibuilderPaths({
|
|
340
|
+
RED,
|
|
341
|
+
config,
|
|
342
|
+
msg,
|
|
343
|
+
instanceName,
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// Create folders
|
|
347
|
+
await fs.ensureDir(srcDir);
|
|
348
|
+
|
|
349
|
+
// Licensing (offline-first): missing/invalid => FREE (watermarked)
|
|
350
|
+
const license = licensing.resolveLicense({ node, RED, licenseConfigNode });
|
|
351
|
+
const licensePublic = {
|
|
352
|
+
licensed: Boolean(license.licensed),
|
|
353
|
+
tier: license.tier || "FREE",
|
|
354
|
+
watermark: Boolean(license.policy && license.policy.watermark),
|
|
355
|
+
brandingLocked: Boolean(license.policy && license.policy.brandingLocked),
|
|
356
|
+
displayName: String(license.display && license.display.displayName ? license.display.displayName : ""),
|
|
357
|
+
reason: String(license.reason || "missing"),
|
|
358
|
+
brandingAttempted: false,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// Logo (optional): absolute path will be copied into src/images automatically
|
|
362
|
+
const logoRequested = isNonEmptyString(msg.options?.logoPath ?? config.logoPath ?? "");
|
|
363
|
+
let logoPathRaw = msg.options?.logoPath ?? config.logoPath ?? "";
|
|
364
|
+
const logoAltRaw = msg.options?.logoAlt ?? config.logoAlt ?? "Logo";
|
|
365
|
+
if (isNonEmptyString(logoPathRaw) && licensePublic.brandingLocked) {
|
|
366
|
+
node.warn("PortalSmith FormGen: custom logo is disabled in Free mode (watermarked). Using default branding.");
|
|
367
|
+
licensePublic.brandingAttempted = Boolean(logoRequested);
|
|
368
|
+
logoPathRaw = "";
|
|
369
|
+
}
|
|
370
|
+
const { logoUrl, logoAlt } = await maybePrepareLogo({ logoPathRaw, logoAltRaw, srcDir });
|
|
371
|
+
|
|
372
|
+
// Submit configuration (optional)
|
|
373
|
+
const submitMode = String(msg.options?.submitMode ?? config.submitMode ?? "uibuilder").trim().toLowerCase();
|
|
374
|
+
const submitUrl = String(msg.options?.submitUrl ?? config.submitUrl ?? "").trim();
|
|
375
|
+
const submitHeadersJson = String(msg.options?.submitHeadersJson ?? config.submitHeadersJson ?? "").trim();
|
|
376
|
+
|
|
377
|
+
if (!["uibuilder", "http"].includes(submitMode)) {
|
|
378
|
+
throw new Error("submitMode must be 'uibuilder' or 'http'");
|
|
379
|
+
}
|
|
380
|
+
if (submitMode === "http" && !isNonEmptyString(submitUrl)) {
|
|
381
|
+
throw new Error("submitUrl is required when submitMode is 'http'");
|
|
382
|
+
}
|
|
383
|
+
if (isNonEmptyString(submitHeadersJson)) {
|
|
384
|
+
try { JSON.parse(submitHeadersJson); } catch (e) { throw new Error("submitHeadersJson must be valid JSON"); }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Define target files (write to src/ folder)
|
|
388
|
+
const targetHtml = path.join(srcDir, "index.html");
|
|
389
|
+
const targetJs = path.join(srcDir, "index.js");
|
|
390
|
+
// Schema goes in src/ so frontend can load it via fetch('./form.schema.json')
|
|
391
|
+
const targetSchema = path.join(srcDir, "form.schema.json");
|
|
392
|
+
// Runtime metadata can stay in root for reference
|
|
393
|
+
const targetRuntime = path.join(instRoot, "portalsmith.runtime.json");
|
|
394
|
+
|
|
395
|
+
// Check overwrite flag
|
|
396
|
+
if (!overwrite) {
|
|
397
|
+
const existingFiles = [targetHtml, targetJs, targetSchema, targetRuntime];
|
|
398
|
+
for (const f of existingFiles) {
|
|
399
|
+
if (await fs.pathExists(f)) {
|
|
400
|
+
throw new Error(`File exists and overwrite=false: ${f}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Calculate schema summary
|
|
406
|
+
const schemaSummary = calculateSchemaSummary(schema);
|
|
407
|
+
|
|
408
|
+
// Load mustache templates
|
|
409
|
+
const templatesDir = path.join(__dirname, "..", "templates");
|
|
410
|
+
const htmlTemplate = await fs.readFile(
|
|
411
|
+
path.join(templatesDir, "index.html.mustache"),
|
|
412
|
+
"utf8"
|
|
413
|
+
);
|
|
414
|
+
const jsTemplate = await fs.readFile(
|
|
415
|
+
path.join(templatesDir, "index.js.mustache"),
|
|
416
|
+
"utf8"
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
// Prepare view model for HTML template
|
|
420
|
+
const baseUrl = config.uibuilderUrl || "/uibuilder";
|
|
421
|
+
const htmlViewModel = {
|
|
422
|
+
title: uiTitle,
|
|
423
|
+
description: schema.description || "",
|
|
424
|
+
baseUrl: baseUrl,
|
|
425
|
+
themeMode: themeMode,
|
|
426
|
+
logoUrl: logoUrl,
|
|
427
|
+
logoAlt: logoAlt,
|
|
428
|
+
licensed: licensePublic.licensed
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Prepare view model for JS template
|
|
432
|
+
const jsViewModel = {
|
|
433
|
+
timestamp: new Date().toISOString(),
|
|
434
|
+
instanceName: instanceName,
|
|
435
|
+
formId: schema.formId || schemaSummary.formId,
|
|
436
|
+
storageMode: storageMode,
|
|
437
|
+
exportFormatsJson: JSON.stringify(exportFormats), // Pass as JSON string
|
|
438
|
+
baseUrl: baseUrl,
|
|
439
|
+
themeMode: themeMode,
|
|
440
|
+
submitMode: submitMode,
|
|
441
|
+
submitUrl: submitUrl,
|
|
442
|
+
submitHeadersJson: submitHeadersJson,
|
|
443
|
+
licenseJson: JSON.stringify(licensePublic)
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Render templates
|
|
447
|
+
// Use custom delimiters for Mustache to avoid conflicts with Vue bindings
|
|
448
|
+
// Mustache will use [[ ]] instead of {{ }}
|
|
449
|
+
const oldTags = Mustache.tags;
|
|
450
|
+
Mustache.tags = ['[[', ']]'];
|
|
451
|
+
let renderedHtml = Mustache.render(htmlTemplate, htmlViewModel);
|
|
452
|
+
let renderedJs = Mustache.render(jsTemplate, jsViewModel);
|
|
453
|
+
Mustache.tags = oldTags; // Restore default
|
|
454
|
+
|
|
455
|
+
// Post-process: replace JSON placeholder with actual JSON (unescaped)
|
|
456
|
+
renderedJs = renderedJs.replace('__EXPORT_FORMATS_JSON__', JSON.stringify(exportFormats));
|
|
457
|
+
renderedJs = renderedJs.replace('__LICENSE_JSON__', jsViewModel.licenseJson);
|
|
458
|
+
|
|
459
|
+
// Write files
|
|
460
|
+
await fs.writeFile(targetHtml, renderedHtml, "utf8");
|
|
461
|
+
await fs.writeFile(targetJs, renderedJs, "utf8");
|
|
462
|
+
await fs.writeJson(targetSchema, schema, { spaces: 2 });
|
|
463
|
+
|
|
464
|
+
// Write runtime metadata
|
|
465
|
+
const runtimeData = {
|
|
466
|
+
generatorVersion: "0.4.9",
|
|
467
|
+
timestamp: new Date().toISOString(),
|
|
468
|
+
instanceName: instanceName,
|
|
469
|
+
storageMode: storageMode,
|
|
470
|
+
exportFormats: exportFormats,
|
|
471
|
+
themeMode: themeMode,
|
|
472
|
+
logoUrl: logoUrl || "",
|
|
473
|
+
logoAlt: logoAlt || "",
|
|
474
|
+
submitMode: submitMode,
|
|
475
|
+
submitUrl: submitUrl,
|
|
476
|
+
license: licensePublic,
|
|
477
|
+
schemaSummary: schemaSummary
|
|
478
|
+
};
|
|
479
|
+
await fs.writeJson(targetRuntime, runtimeData, { spaces: 2 });
|
|
480
|
+
|
|
481
|
+
// Set output payload
|
|
482
|
+
msg.payload = {
|
|
483
|
+
status: "generated",
|
|
484
|
+
instance: instanceName,
|
|
485
|
+
uibRoot: uibRoot,
|
|
486
|
+
path: instRoot,
|
|
487
|
+
srcPath: srcDir,
|
|
488
|
+
userDir: userDir,
|
|
489
|
+
url: `${baseUrl}/${instanceName}/`,
|
|
490
|
+
files: ["src/index.html", "src/index.js", "src/form.schema.json", "portalsmith.runtime.json"],
|
|
491
|
+
schemaSummary: schemaSummary
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
send(msg);
|
|
495
|
+
done();
|
|
496
|
+
} catch (err) {
|
|
497
|
+
node.error(err, msg);
|
|
498
|
+
done(err);
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function validateSchema(schema) {
|
|
504
|
+
if (!schema.schemaVersion) {
|
|
505
|
+
throw new Error("Schema missing required 'schemaVersion' field");
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (!schema.sections || !Array.isArray(schema.sections)) {
|
|
509
|
+
throw new Error("Schema missing required 'sections' array");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (schema.sections.length === 0) {
|
|
513
|
+
throw new Error("Schema must have at least one section");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Validate each section
|
|
517
|
+
schema.sections.forEach((section, idx) => {
|
|
518
|
+
if (!section.fields || !Array.isArray(section.fields)) {
|
|
519
|
+
throw new Error(`Section ${idx} missing required 'fields' array`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (section.fields.length === 0) {
|
|
523
|
+
throw new Error(`Section ${idx} must have at least one field`);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Validate each field
|
|
527
|
+
section.fields.forEach((field, fieldIdx) => {
|
|
528
|
+
if (!field.id && !field.key) {
|
|
529
|
+
throw new Error(`Section ${idx}, field ${fieldIdx} missing required 'id' or 'key'`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Normalize: use 'id' if 'key' is present
|
|
533
|
+
if (field.key && !field.id) {
|
|
534
|
+
field.id = field.key;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!field.type) {
|
|
538
|
+
throw new Error(`Section ${idx}, field ${field.id} missing required 'type'`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const validTypes = ["text", "textarea", "number", "select", "checkbox", "radio", "date", "keyvalue"];
|
|
542
|
+
if (!validTypes.includes(field.type)) {
|
|
543
|
+
throw new Error(`Section ${idx}, field ${field.id} has invalid type: ${field.type}`);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Validate select/radio options
|
|
547
|
+
if ((field.type === "select" || field.type === "radio") && !field.options) {
|
|
548
|
+
throw new Error(`Section ${idx}, field ${field.id} (${field.type}) missing required 'options'`);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Validate keyvalue fields
|
|
552
|
+
if (field.type === "keyvalue") {
|
|
553
|
+
const mode = field.keyvalueMode || "pairs";
|
|
554
|
+
if (mode === "pairs") {
|
|
555
|
+
if (!field.pairs || !Array.isArray(field.pairs) || field.pairs.length === 0) {
|
|
556
|
+
throw new Error(`Section ${idx}, field ${field.id} (keyvalue pairs mode) requires at least one key-value pair`);
|
|
557
|
+
}
|
|
558
|
+
} else if (mode === "delimiter") {
|
|
559
|
+
if (!field.keyvalueDelimiter || String(field.keyvalueDelimiter).trim() === "") {
|
|
560
|
+
throw new Error(`Section ${idx}, field ${field.id} (keyvalue delimiter mode) requires a delimiter`);
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
throw new Error(`Section ${idx}, field ${field.id} (keyvalue) has invalid mode: ${mode}. Must be 'pairs' or 'delimiter'`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function calculateSchemaSummary(schema) {
|
|
571
|
+
const formId = schema.formId || "unknown";
|
|
572
|
+
const title = schema.title || "Untitled Form";
|
|
573
|
+
const sectionCount = schema.sections ? schema.sections.length : 0;
|
|
574
|
+
|
|
575
|
+
let fieldCount = 0;
|
|
576
|
+
if (schema.sections) {
|
|
577
|
+
schema.sections.forEach(section => {
|
|
578
|
+
if (section.fields) {
|
|
579
|
+
fieldCount += section.fields.length;
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
formId: formId,
|
|
586
|
+
title: title,
|
|
587
|
+
sectionCount: sectionCount,
|
|
588
|
+
fieldCount: fieldCount
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
RED.nodes.registerType("uibuilder-formgen", UibuilderFormGenNode, {
|
|
593
|
+
credentials: {
|
|
594
|
+
licenseKey: { type: "password" },
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
};
|
|
598
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cyprnet/node-red-contrib-uibuilder-formgen",
|
|
3
|
+
"version": "0.4.11",
|
|
4
|
+
"description": "PortalSmith: Generate schema-driven uibuilder form portals from JSON",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"node-red",
|
|
7
|
+
"node-red-contrib",
|
|
8
|
+
"uibuilder",
|
|
9
|
+
"form",
|
|
10
|
+
"form-generator",
|
|
11
|
+
"schema",
|
|
12
|
+
"json-schema",
|
|
13
|
+
"portal",
|
|
14
|
+
"workflow-automation",
|
|
15
|
+
"bootstrap",
|
|
16
|
+
"vue"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "CyprNet Solutions, LLC (CyprNet Solutions)",
|
|
20
|
+
"homepage": "https://www.cyprnetsolutions.com",
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"files": [
|
|
23
|
+
"nodes",
|
|
24
|
+
"templates",
|
|
25
|
+
"examples",
|
|
26
|
+
"docs",
|
|
27
|
+
"scripts",
|
|
28
|
+
"lib",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"CHANGELOG.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"lint": "echo \"(optional) add eslint later\"",
|
|
35
|
+
"test": "echo \"no tests yet\""
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"fs-extra": "^11.2.0",
|
|
39
|
+
"mustache": "^4.2.0"
|
|
40
|
+
},
|
|
41
|
+
"node-red": {
|
|
42
|
+
"nodes": {
|
|
43
|
+
"uibuilder-formgen": "nodes/uibuilder-formgen.js",
|
|
44
|
+
"portalsmith-license": "nodes/portalsmith-license.js"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|