@uportal/form-builder 2.0.0 → 2.1.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.
- package/README.md +77 -12
- package/dist/form-builder.js +995 -161
- package/dist/form-builder.js.map +1 -1
- package/dist/form-builder.min.js +286 -73
- package/dist/form-builder.min.js.map +1 -1
- package/package.json +36 -8
- package/src/form-builder.js +937 -159
- package/src/index.js +0 -4
package/src/form-builder.js
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
import { LitElement, html, css } from 'lit';
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
function delay(ms) {
|
|
5
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
6
|
-
}
|
|
2
|
+
import { jwtDecode } from 'jwt-decode';
|
|
7
3
|
|
|
8
4
|
/**
|
|
9
5
|
* Dynamic Form Builder Web Component
|
|
10
6
|
* Fetches JSON schema and form data, then renders a dynamic form
|
|
11
|
-
*
|
|
7
|
+
*
|
|
12
8
|
* @element form-builder
|
|
13
|
-
*
|
|
9
|
+
*
|
|
14
10
|
* @attr {string} fbms-base-url - Base URL of the form builder microservice
|
|
15
11
|
* @attr {string} fbms-form-fname - Form name to fetch
|
|
16
12
|
* @attr {string} oidc-url - OpenID Connect URL for authentication
|
|
@@ -25,7 +21,7 @@ class FormBuilder extends LitElement {
|
|
|
25
21
|
|
|
26
22
|
// Internal state
|
|
27
23
|
schema: { type: Object, state: true },
|
|
28
|
-
|
|
24
|
+
_formData: { type: Object, state: true },
|
|
29
25
|
uiSchema: { type: Object, state: true },
|
|
30
26
|
fbmsFormVersion: { type: String, state: true },
|
|
31
27
|
loading: { type: Boolean, state: true },
|
|
@@ -33,12 +29,20 @@ class FormBuilder extends LitElement {
|
|
|
33
29
|
error: { type: String, state: true },
|
|
34
30
|
token: { type: String, state: true },
|
|
35
31
|
decoded: { type: Object, state: true },
|
|
32
|
+
submitSuccess: { type: Boolean, state: true },
|
|
33
|
+
validationFailed: { type: Boolean, state: true },
|
|
34
|
+
initialFormData: { type: Object, state: true },
|
|
35
|
+
hasChanges: { type: Boolean, state: true },
|
|
36
|
+
submissionStatus: { type: Object, state: true },
|
|
37
|
+
formCompleted: { type: Boolean, state: true },
|
|
38
|
+
submissionError: { type: String, state: true },
|
|
36
39
|
};
|
|
37
40
|
|
|
38
41
|
static styles = css`
|
|
39
42
|
:host {
|
|
40
43
|
display: block;
|
|
41
|
-
font-family:
|
|
44
|
+
font-family:
|
|
45
|
+
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
.container {
|
|
@@ -71,7 +75,27 @@ class FormBuilder extends LitElement {
|
|
|
71
75
|
.form-group {
|
|
72
76
|
display: flex;
|
|
73
77
|
flex-direction: column;
|
|
74
|
-
gap:
|
|
78
|
+
gap: 4px;
|
|
79
|
+
margin: 14px 0px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.nested-object {
|
|
83
|
+
margin-left: 20px;
|
|
84
|
+
padding-left: 20px;
|
|
85
|
+
border-left: 2px solid #e0e0e0;
|
|
86
|
+
margin-top: 10px;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.nested-object-title {
|
|
90
|
+
font-weight: 600;
|
|
91
|
+
color: #333;
|
|
92
|
+
margin-bottom: 10px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.nested-object-description {
|
|
96
|
+
font-size: 0.875rem;
|
|
97
|
+
color: #666;
|
|
98
|
+
margin-bottom: 15px;
|
|
75
99
|
}
|
|
76
100
|
|
|
77
101
|
label {
|
|
@@ -90,11 +114,11 @@ class FormBuilder extends LitElement {
|
|
|
90
114
|
margin-top: 4px;
|
|
91
115
|
}
|
|
92
116
|
|
|
93
|
-
input[type=
|
|
94
|
-
input[type=
|
|
95
|
-
input[type=
|
|
96
|
-
input[type=
|
|
97
|
-
input[type=
|
|
117
|
+
input[type='text'],
|
|
118
|
+
input[type='email'],
|
|
119
|
+
input[type='number'],
|
|
120
|
+
input[type='date'],
|
|
121
|
+
input[type='tel'],
|
|
98
122
|
textarea,
|
|
99
123
|
select {
|
|
100
124
|
padding: 8px 12px;
|
|
@@ -114,16 +138,40 @@ class FormBuilder extends LitElement {
|
|
|
114
138
|
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
|
|
115
139
|
}
|
|
116
140
|
|
|
141
|
+
select[multiple] {
|
|
142
|
+
min-height: 120px;
|
|
143
|
+
padding: 4px;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
select[multiple] option {
|
|
147
|
+
padding: 4px 8px;
|
|
148
|
+
}
|
|
149
|
+
|
|
117
150
|
textarea {
|
|
118
151
|
min-height: 100px;
|
|
119
152
|
resize: vertical;
|
|
120
153
|
}
|
|
121
154
|
|
|
122
|
-
input[type=
|
|
123
|
-
input[type=
|
|
155
|
+
input[type='checkbox'],
|
|
156
|
+
input[type='radio'] {
|
|
124
157
|
margin-right: 8px;
|
|
125
158
|
}
|
|
126
159
|
|
|
160
|
+
fieldset {
|
|
161
|
+
border: none;
|
|
162
|
+
padding: 0;
|
|
163
|
+
margin: 0;
|
|
164
|
+
min-width: 0; /* Fix for some browsers */
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
legend {
|
|
168
|
+
font-weight: 700;
|
|
169
|
+
color: #333;
|
|
170
|
+
padding: 0;
|
|
171
|
+
margin-bottom: 8px;
|
|
172
|
+
font-size: 1rem;
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
.checkbox-group,
|
|
128
176
|
.radio-group {
|
|
129
177
|
display: flex;
|
|
@@ -137,6 +185,18 @@ class FormBuilder extends LitElement {
|
|
|
137
185
|
align-items: center;
|
|
138
186
|
}
|
|
139
187
|
|
|
188
|
+
.checkbox-group.inline,
|
|
189
|
+
.radio-group.inline {
|
|
190
|
+
flex-direction: row;
|
|
191
|
+
flex-wrap: wrap;
|
|
192
|
+
gap: 16px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.checkbox-group.inline .checkbox-item,
|
|
196
|
+
.radio-group.inline .radio-item {
|
|
197
|
+
margin-right: 0;
|
|
198
|
+
}
|
|
199
|
+
|
|
140
200
|
.error-message {
|
|
141
201
|
color: #c00;
|
|
142
202
|
font-size: 0.875rem;
|
|
@@ -159,21 +219,21 @@ class FormBuilder extends LitElement {
|
|
|
159
219
|
transition: background-color 0.2s;
|
|
160
220
|
}
|
|
161
221
|
|
|
162
|
-
button[type=
|
|
222
|
+
button[type='submit'] {
|
|
163
223
|
background-color: #0066cc;
|
|
164
224
|
color: white;
|
|
165
225
|
}
|
|
166
226
|
|
|
167
|
-
button[type=
|
|
227
|
+
button[type='submit']:hover {
|
|
168
228
|
background-color: #0052a3;
|
|
169
229
|
}
|
|
170
230
|
|
|
171
|
-
button[type=
|
|
231
|
+
button[type='button'] {
|
|
172
232
|
background-color: #c0c0c0;
|
|
173
233
|
color: #333;
|
|
174
234
|
}
|
|
175
235
|
|
|
176
|
-
button[type=
|
|
236
|
+
button[type='button']:hover {
|
|
177
237
|
background-color: #e0e0e0;
|
|
178
238
|
}
|
|
179
239
|
|
|
@@ -195,7 +255,9 @@ class FormBuilder extends LitElement {
|
|
|
195
255
|
}
|
|
196
256
|
|
|
197
257
|
@keyframes spin {
|
|
198
|
-
to {
|
|
258
|
+
to {
|
|
259
|
+
transform: rotate(360deg);
|
|
260
|
+
}
|
|
199
261
|
}
|
|
200
262
|
|
|
201
263
|
.button-content {
|
|
@@ -203,20 +265,117 @@ class FormBuilder extends LitElement {
|
|
|
203
265
|
align-items: center;
|
|
204
266
|
justify-content: center;
|
|
205
267
|
}
|
|
268
|
+
|
|
269
|
+
.status-message {
|
|
270
|
+
padding: 12px 16px;
|
|
271
|
+
border-radius: 4px;
|
|
272
|
+
margin-bottom: 20px;
|
|
273
|
+
font-weight: 500;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.status-message.success {
|
|
277
|
+
background-color: #d4edda;
|
|
278
|
+
border: 1px solid #c3e6cb;
|
|
279
|
+
color: #155724;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.status-message.validation-error {
|
|
283
|
+
background-color: #fff3cd;
|
|
284
|
+
border: 1px solid #ffeaa7;
|
|
285
|
+
color: #856404;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.status-message.error {
|
|
289
|
+
background-color: #f8d7da;
|
|
290
|
+
border: 1px solid #f5c6cb;
|
|
291
|
+
color: #721c24;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.status-message ul {
|
|
295
|
+
margin: 8px 0 0 0;
|
|
296
|
+
padding-left: 20px;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.status-message li {
|
|
300
|
+
margin: 4px 0;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
form.submitting input,
|
|
304
|
+
form.submitting textarea,
|
|
305
|
+
form.submitting select,
|
|
306
|
+
form.submitting button:not([type='submit']) {
|
|
307
|
+
opacity: 0.6;
|
|
308
|
+
pointer-events: none;
|
|
309
|
+
cursor: not-allowed;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.info-only {
|
|
313
|
+
padding: 20px 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.info-only p {
|
|
317
|
+
line-height: 1.6;
|
|
318
|
+
color: #333;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.info-label {
|
|
322
|
+
font-weight: 500;
|
|
323
|
+
color: #333;
|
|
324
|
+
display: block;
|
|
325
|
+
}
|
|
206
326
|
`;
|
|
207
327
|
|
|
328
|
+
// Getter and setter for formData
|
|
329
|
+
get formData() {
|
|
330
|
+
return this._formData;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
set formData(value) {
|
|
334
|
+
const oldValue = this._formData;
|
|
335
|
+
this._formData = value;
|
|
336
|
+
this.requestUpdate('formData', oldValue);
|
|
337
|
+
this.updateStateFlags();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Get custom error message from schema if available
|
|
342
|
+
* Follows the pattern: schema.properties.fieldName.messages.ruleName
|
|
343
|
+
* For nested fields: schema.properties.parent.properties.child.messages.ruleName
|
|
344
|
+
* Returns null if field, messages, or rule doesn't exist
|
|
345
|
+
*/
|
|
346
|
+
getCustomErrorMessage(fieldPath, ruleName) {
|
|
347
|
+
const pathParts = fieldPath.split('.');
|
|
348
|
+
let current = this.schema;
|
|
349
|
+
|
|
350
|
+
// Navigate to the field schema
|
|
351
|
+
for (const part of pathParts) {
|
|
352
|
+
current = current?.properties?.[part];
|
|
353
|
+
if (!current) return null;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Check for custom message
|
|
357
|
+
return current?.messages?.[ruleName] ?? null;
|
|
358
|
+
}
|
|
359
|
+
|
|
208
360
|
constructor() {
|
|
209
361
|
super();
|
|
210
362
|
this.loading = true;
|
|
211
363
|
this.submitting = false;
|
|
212
364
|
this.error = null;
|
|
213
365
|
this.schema = null;
|
|
214
|
-
this.
|
|
366
|
+
this._formData = {};
|
|
215
367
|
this.uiSchema = null;
|
|
216
368
|
this.fbmsFormVersion = null;
|
|
217
369
|
this.token = null;
|
|
218
|
-
this.decoded = {sub: 'unknown'};
|
|
370
|
+
this.decoded = { sub: 'unknown' };
|
|
219
371
|
this.fieldErrors = {};
|
|
372
|
+
this.submitSuccess = false;
|
|
373
|
+
this.validationFailed = false;
|
|
374
|
+
this.initialFormData = {};
|
|
375
|
+
this.hasChanges = false;
|
|
376
|
+
this.submissionStatus = null;
|
|
377
|
+
this.formCompleted = false;
|
|
378
|
+
this.submissionError = null;
|
|
220
379
|
}
|
|
221
380
|
|
|
222
381
|
async connectedCallback() {
|
|
@@ -235,10 +394,7 @@ class FormBuilder extends LitElement {
|
|
|
235
394
|
}
|
|
236
395
|
|
|
237
396
|
// Fetch form schema and data
|
|
238
|
-
await Promise.all([
|
|
239
|
-
this.fetchSchema(),
|
|
240
|
-
this.fetchFormData(),
|
|
241
|
-
]);
|
|
397
|
+
await Promise.all([this.fetchSchema(), this.fetchFormData()]);
|
|
242
398
|
|
|
243
399
|
this.loading = false;
|
|
244
400
|
} catch (err) {
|
|
@@ -247,6 +403,80 @@ class FormBuilder extends LitElement {
|
|
|
247
403
|
}
|
|
248
404
|
}
|
|
249
405
|
|
|
406
|
+
/**
|
|
407
|
+
* Deep clone an object, handling Dates and other types
|
|
408
|
+
* Uses structuredClone if available, otherwise falls back to manual recursive
|
|
409
|
+
*/
|
|
410
|
+
deepClone(obj) {
|
|
411
|
+
if (obj === null || obj === undefined) return obj;
|
|
412
|
+
|
|
413
|
+
// Use structuredClone if available (modern browsers)
|
|
414
|
+
if (typeof structuredClone === 'function') {
|
|
415
|
+
try {
|
|
416
|
+
return structuredClone(obj);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
console.warn('structuredClone failed, falling back to manual clone:', err);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Fallback: manual deep clone handling common types
|
|
423
|
+
if (obj instanceof Date) {
|
|
424
|
+
return new Date(obj.getTime());
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (Array.isArray(obj)) {
|
|
428
|
+
return obj.map((item) => this.deepClone(item));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (typeof obj === 'object') {
|
|
432
|
+
const cloned = {};
|
|
433
|
+
for (const key in obj) {
|
|
434
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
435
|
+
cloned[key] = this.deepClone(obj[key]);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return cloned;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Primitives
|
|
442
|
+
return obj;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Deep equality check, handling Dates and other types
|
|
447
|
+
*/
|
|
448
|
+
deepEqual(obj1, obj2) {
|
|
449
|
+
if (obj1 === obj2) return true;
|
|
450
|
+
|
|
451
|
+
if (obj1 === null || obj2 === null) return false;
|
|
452
|
+
if (obj1 === undefined || obj2 === undefined) return false;
|
|
453
|
+
|
|
454
|
+
if (obj1 instanceof Date && obj2 instanceof Date) {
|
|
455
|
+
return obj1.getTime() === obj2.getTime();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// If only one is a Date, they are not equal
|
|
459
|
+
if (obj1 instanceof Date || obj2 instanceof Date) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (Array.isArray(obj1) && Array.isArray(obj2)) {
|
|
464
|
+
if (obj1.length !== obj2.length) return false;
|
|
465
|
+
return obj1.every((item, index) => this.deepEqual(item, obj2[index]));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (typeof obj1 === 'object' && typeof obj2 === 'object') {
|
|
469
|
+
const keys1 = Object.keys(obj1);
|
|
470
|
+
const keys2 = Object.keys(obj2);
|
|
471
|
+
|
|
472
|
+
if (keys1.length !== keys2.length) return false;
|
|
473
|
+
|
|
474
|
+
return keys1.every((key) => this.deepEqual(obj1[key], obj2[key]));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
|
|
250
480
|
async fetchToken() {
|
|
251
481
|
try {
|
|
252
482
|
const response = await fetch(this.oidcUrl, {
|
|
@@ -258,8 +488,14 @@ class FormBuilder extends LitElement {
|
|
|
258
488
|
}
|
|
259
489
|
|
|
260
490
|
const data = await response.text();
|
|
261
|
-
this.token = data
|
|
262
|
-
|
|
491
|
+
this.token = data;
|
|
492
|
+
try {
|
|
493
|
+
this.decoded = jwtDecode(this.token);
|
|
494
|
+
} catch (_err) {
|
|
495
|
+
// Only need this to get the name, so warn
|
|
496
|
+
console.warn('Security Token failed to decode -- setting user to unknown');
|
|
497
|
+
this.decoded = { sub: 'unknown' };
|
|
498
|
+
}
|
|
263
499
|
} catch (err) {
|
|
264
500
|
console.error('Token fetch error:', err);
|
|
265
501
|
throw new Error('Authentication failed');
|
|
@@ -309,66 +545,250 @@ class FormBuilder extends LitElement {
|
|
|
309
545
|
|
|
310
546
|
if (response.ok) {
|
|
311
547
|
const payload = await response.json();
|
|
312
|
-
this.
|
|
548
|
+
this._formData = payload?.answers ?? {}; // Use private property
|
|
549
|
+
this.initialFormData = this.deepClone(this._formData); // Use deepClone
|
|
313
550
|
} else {
|
|
314
|
-
|
|
315
|
-
this.
|
|
551
|
+
this._formData = {};
|
|
552
|
+
this.initialFormData = {};
|
|
316
553
|
}
|
|
554
|
+
this.hasChanges = false;
|
|
555
|
+
this.requestUpdate();
|
|
317
556
|
} catch (err) {
|
|
318
557
|
// Non-critical error
|
|
319
558
|
console.warn('Could not fetch form data:', err);
|
|
320
|
-
this.
|
|
559
|
+
this._formData = {};
|
|
560
|
+
this.initialFormData = {};
|
|
561
|
+
this.hasChanges = false;
|
|
562
|
+
this.requestUpdate();
|
|
321
563
|
}
|
|
322
564
|
}
|
|
323
565
|
|
|
324
|
-
|
|
325
|
-
|
|
566
|
+
updateStateFlags() {
|
|
567
|
+
// Clear status messages when user makes changes
|
|
568
|
+
this.submitSuccess = false;
|
|
569
|
+
this.validationFailed = false;
|
|
570
|
+
this.submissionError = null;
|
|
326
571
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
572
|
+
// Check if form data has changed from initial state
|
|
573
|
+
this.hasChanges = !this.deepEqual(this.formData, this.initialFormData);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Get nested value from formData using dot notation path
|
|
578
|
+
* e.g., "contact_information.email" => formData.contact_information.email
|
|
579
|
+
*/
|
|
580
|
+
getNestedValue(path) {
|
|
581
|
+
if (!path || typeof path !== 'string') return undefined;
|
|
582
|
+
|
|
583
|
+
const parts = path.split('.').filter((part) => part.length > 0);
|
|
584
|
+
if (parts.length === 0) return undefined;
|
|
585
|
+
|
|
586
|
+
let value = this.formData;
|
|
587
|
+
for (const part of parts) {
|
|
588
|
+
value = value?.[part];
|
|
589
|
+
}
|
|
590
|
+
return value;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Sanitize a string for use as an HTML ID
|
|
595
|
+
* Replaces spaces and special characters with hyphens and collapses consecutive hyphens
|
|
596
|
+
* Ensures the ID starts with a letter by adding a prefix if necessary
|
|
597
|
+
*/
|
|
598
|
+
sanitizeId(str) {
|
|
599
|
+
if (typeof str !== 'string') {
|
|
600
|
+
str = String(str ?? '');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Replace invalid characters and collapse multiple hyphens
|
|
604
|
+
let sanitized = str.replace(/[^a-zA-Z0-9-_.]/g, '-').replace(/-+/g, '-');
|
|
605
|
+
|
|
606
|
+
// Trim leading/trailing hyphens that may have been introduced
|
|
607
|
+
sanitized = sanitized.replace(/^-+/, '').replace(/-+$/, '');
|
|
608
|
+
|
|
609
|
+
// Ensure we have some content
|
|
610
|
+
if (!sanitized) {
|
|
611
|
+
sanitized = 'id';
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Ensure the ID starts with a letter
|
|
615
|
+
if (!/^[A-Za-z]/.test(sanitized)) {
|
|
616
|
+
sanitized = 'id-' + sanitized;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return sanitized;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Set nested value in formData using dot notation path
|
|
624
|
+
*/
|
|
625
|
+
setNestedValue(path, value) {
|
|
626
|
+
if (!path || typeof path !== 'string') return;
|
|
627
|
+
|
|
628
|
+
const parts = path.split('.').filter((part) => part.length > 0);
|
|
629
|
+
if (parts.length === 0) return;
|
|
630
|
+
|
|
631
|
+
const newData = { ...this.formData };
|
|
632
|
+
let current = newData;
|
|
633
|
+
|
|
634
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
635
|
+
const part = parts[i];
|
|
636
|
+
const existing = current[part];
|
|
637
|
+
// Note: Arrays are not currently supported in schemas, but we preserve them
|
|
638
|
+
// to maintain data integrity. Setting properties on arrays may produce unexpected results.
|
|
639
|
+
if (Array.isArray(existing)) {
|
|
640
|
+
current[part] = [...existing];
|
|
641
|
+
} else if (!existing || typeof existing !== 'object') {
|
|
642
|
+
current[part] = {};
|
|
643
|
+
} else {
|
|
644
|
+
current[part] = { ...existing };
|
|
645
|
+
}
|
|
646
|
+
current = current[part];
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
current[parts[parts.length - 1]] = value;
|
|
650
|
+
this.formData = newData;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Get the schema object at a given path
|
|
655
|
+
* e.g., "contact_information" => schema.properties.contact_information
|
|
656
|
+
*/
|
|
657
|
+
getSchemaAtPath(path) {
|
|
658
|
+
if (!path) return this.schema; // Handle empty string/null/undefined
|
|
659
|
+
|
|
660
|
+
const parts = path.split('.').filter((part) => part.length > 0);
|
|
661
|
+
if (parts.length === 0) return this.schema; // All segments were empty
|
|
662
|
+
|
|
663
|
+
let schema = this.schema;
|
|
664
|
+
|
|
665
|
+
for (const part of parts) {
|
|
666
|
+
schema = schema.properties?.[part];
|
|
667
|
+
if (!schema) return null;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return schema;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
handleInputChange(fieldPath, event) {
|
|
674
|
+
const { type, value, checked } = event.target;
|
|
675
|
+
this.setNestedValue(fieldPath, type === 'checkbox' ? checked : value);
|
|
331
676
|
|
|
332
677
|
// Clear field error on change
|
|
333
|
-
if (this.fieldErrors[
|
|
678
|
+
if (this.fieldErrors[fieldPath]) {
|
|
334
679
|
this.fieldErrors = { ...this.fieldErrors };
|
|
335
|
-
delete this.fieldErrors[
|
|
680
|
+
delete this.fieldErrors[fieldPath];
|
|
336
681
|
}
|
|
682
|
+
|
|
683
|
+
this.updateStateFlags();
|
|
337
684
|
}
|
|
338
685
|
|
|
339
|
-
handleArrayChange(
|
|
340
|
-
const currentArray = this.
|
|
686
|
+
handleArrayChange(fieldPath, index, event) {
|
|
687
|
+
const currentArray = this.getNestedValue(fieldPath) || [];
|
|
341
688
|
const newArray = [...currentArray];
|
|
342
689
|
newArray[index] = event.target.value;
|
|
690
|
+
this.setNestedValue(fieldPath, newArray);
|
|
343
691
|
|
|
344
|
-
this.
|
|
345
|
-
...this.formData,
|
|
346
|
-
[fieldName]: newArray,
|
|
347
|
-
};
|
|
692
|
+
this.updateStateFlags();
|
|
348
693
|
}
|
|
349
694
|
|
|
350
|
-
|
|
695
|
+
handleMultiSelectChange(fieldPath, event) {
|
|
696
|
+
const selectedOptions = Array.from(event.target.selectedOptions);
|
|
697
|
+
const values = selectedOptions.map((option) => option.value);
|
|
698
|
+
|
|
699
|
+
this.setNestedValue(fieldPath, values);
|
|
700
|
+
|
|
701
|
+
// Clear field error on change
|
|
702
|
+
if (this.fieldErrors[fieldPath]) {
|
|
703
|
+
this.fieldErrors = { ...this.fieldErrors };
|
|
704
|
+
delete this.fieldErrors[fieldPath];
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
this.updateStateFlags();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
handleCheckboxArrayChange(fieldPath, optionValue, event) {
|
|
711
|
+
const { checked } = event.target;
|
|
712
|
+
const currentArray = this.getNestedValue(fieldPath) || [];
|
|
713
|
+
|
|
714
|
+
let newArray;
|
|
715
|
+
if (checked) {
|
|
716
|
+
// Add to array if not already present
|
|
717
|
+
newArray = currentArray.includes(optionValue) ? currentArray : [...currentArray, optionValue];
|
|
718
|
+
} else {
|
|
719
|
+
// Remove from array
|
|
720
|
+
newArray = currentArray.filter((v) => v !== optionValue);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
this.setNestedValue(fieldPath, newArray);
|
|
724
|
+
|
|
725
|
+
// Clear field error on change
|
|
726
|
+
if (this.fieldErrors[fieldPath]) {
|
|
727
|
+
this.fieldErrors = { ...this.fieldErrors };
|
|
728
|
+
delete this.fieldErrors[fieldPath];
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
this.updateStateFlags();
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Recursively validate form fields including nested objects
|
|
736
|
+
*/
|
|
737
|
+
validateFormFields(properties, required = [], basePath = '', depth = 0) {
|
|
738
|
+
const MAX_DEPTH = 10;
|
|
739
|
+
if (depth > MAX_DEPTH) {
|
|
740
|
+
console.warn(`Schema nesting exceeds maximum depth of ${MAX_DEPTH} at path: ${basePath}`);
|
|
741
|
+
return {};
|
|
742
|
+
}
|
|
743
|
+
|
|
351
744
|
const errors = {};
|
|
352
|
-
const { properties = {}, required = [] } = this.schema;
|
|
353
745
|
|
|
354
746
|
// Check required fields
|
|
355
|
-
required.forEach(fieldName => {
|
|
356
|
-
const
|
|
747
|
+
required.forEach((fieldName) => {
|
|
748
|
+
const fieldPath = basePath ? `${basePath}.${fieldName}` : fieldName;
|
|
749
|
+
const value = this.getNestedValue(fieldPath);
|
|
357
750
|
if (value === undefined || value === null || value === '') {
|
|
358
|
-
|
|
751
|
+
const customMsg = this.getCustomErrorMessage(fieldPath, 'required');
|
|
752
|
+
errors[fieldPath] = customMsg || 'This field is required';
|
|
359
753
|
}
|
|
360
754
|
});
|
|
361
755
|
|
|
362
756
|
// Type validation
|
|
363
757
|
Object.entries(properties).forEach(([fieldName, fieldSchema]) => {
|
|
364
|
-
const
|
|
758
|
+
const fieldPath = basePath ? `${basePath}.${fieldName}` : fieldName;
|
|
759
|
+
const value = this.getNestedValue(fieldPath);
|
|
760
|
+
|
|
761
|
+
// Handle nested objects recursively
|
|
762
|
+
if (fieldSchema.type === 'object' && fieldSchema.properties) {
|
|
763
|
+
const nestedErrors = this.validateFormFields(
|
|
764
|
+
fieldSchema.properties,
|
|
765
|
+
fieldSchema.required || [],
|
|
766
|
+
fieldPath,
|
|
767
|
+
depth + 1
|
|
768
|
+
);
|
|
769
|
+
Object.assign(errors, nestedErrors);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
365
772
|
|
|
366
773
|
if (value !== undefined && value !== null && value !== '') {
|
|
367
774
|
// Email validation
|
|
368
775
|
if (fieldSchema.format === 'email') {
|
|
369
776
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
370
777
|
if (!emailRegex.test(value)) {
|
|
371
|
-
|
|
778
|
+
// Support both 'format' (generic) and 'email' (specific) custom message keys
|
|
779
|
+
const customMsg =
|
|
780
|
+
this.getCustomErrorMessage(fieldPath, 'format') ||
|
|
781
|
+
this.getCustomErrorMessage(fieldPath, 'email');
|
|
782
|
+
errors[fieldPath] = customMsg || 'Invalid email address';
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Pattern validation
|
|
787
|
+
if (fieldSchema.pattern) {
|
|
788
|
+
const regex = new RegExp(fieldSchema.pattern);
|
|
789
|
+
if (!regex.test(value)) {
|
|
790
|
+
const customMsg = this.getCustomErrorMessage(fieldPath, 'pattern');
|
|
791
|
+
errors[fieldPath] = customMsg || 'Invalid format';
|
|
372
792
|
}
|
|
373
793
|
}
|
|
374
794
|
|
|
@@ -376,13 +796,16 @@ class FormBuilder extends LitElement {
|
|
|
376
796
|
if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') {
|
|
377
797
|
const num = Number(value);
|
|
378
798
|
if (isNaN(num)) {
|
|
379
|
-
|
|
799
|
+
const customMsg = this.getCustomErrorMessage(fieldPath, 'type');
|
|
800
|
+
errors[fieldPath] = customMsg || 'Must be a number';
|
|
380
801
|
} else {
|
|
381
802
|
if (fieldSchema.minimum !== undefined && num < fieldSchema.minimum) {
|
|
382
|
-
|
|
803
|
+
const customMsg = this.getCustomErrorMessage(fieldPath, 'minimum');
|
|
804
|
+
errors[fieldPath] = customMsg || `Must be at least ${fieldSchema.minimum}`;
|
|
383
805
|
}
|
|
384
806
|
if (fieldSchema.maximum !== undefined && num > fieldSchema.maximum) {
|
|
385
|
-
|
|
807
|
+
const customMsg = this.getCustomErrorMessage(fieldPath, 'maximum');
|
|
808
|
+
errors[fieldPath] = customMsg || `Must be at most ${fieldSchema.maximum}`;
|
|
386
809
|
}
|
|
387
810
|
}
|
|
388
811
|
}
|
|
@@ -390,24 +813,41 @@ class FormBuilder extends LitElement {
|
|
|
390
813
|
// String length validation
|
|
391
814
|
if (fieldSchema.type === 'string') {
|
|
392
815
|
if (fieldSchema.minLength && value.length < fieldSchema.minLength) {
|
|
393
|
-
|
|
816
|
+
const customMsg = this.getCustomErrorMessage(fieldPath, 'minLength');
|
|
817
|
+
errors[fieldPath] = customMsg || `Must be at least ${fieldSchema.minLength} characters`;
|
|
394
818
|
}
|
|
395
819
|
if (fieldSchema.maxLength && value.length > fieldSchema.maxLength) {
|
|
396
|
-
|
|
820
|
+
const customMsg = this.getCustomErrorMessage(fieldPath, 'maxLength');
|
|
821
|
+
errors[fieldPath] = customMsg || `Must be at most ${fieldSchema.maxLength} characters`;
|
|
397
822
|
}
|
|
398
823
|
}
|
|
399
824
|
}
|
|
400
825
|
});
|
|
401
826
|
|
|
402
|
-
|
|
403
|
-
|
|
827
|
+
return errors;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
validateForm() {
|
|
831
|
+
const { properties = {}, required = [] } = this.schema;
|
|
832
|
+
this.fieldErrors = this.validateFormFields(properties, required);
|
|
833
|
+
return Object.keys(this.fieldErrors).length === 0;
|
|
404
834
|
}
|
|
405
835
|
|
|
406
836
|
async handleSubmit(event) {
|
|
407
837
|
event.preventDefault();
|
|
408
838
|
|
|
839
|
+
// Clear previous status messages
|
|
840
|
+
this.submitSuccess = false;
|
|
841
|
+
this.validationFailed = false;
|
|
409
842
|
if (!this.validateForm()) {
|
|
410
|
-
this.
|
|
843
|
+
this.validationFailed = true;
|
|
844
|
+
await this.updateComplete; // Wait for render to complete
|
|
845
|
+
|
|
846
|
+
// Scroll to validation warning banner at top of form
|
|
847
|
+
const validationWarning = this.shadowRoot.querySelector('.status-message.validation-error');
|
|
848
|
+
if (validationWarning) {
|
|
849
|
+
validationWarning.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
850
|
+
}
|
|
411
851
|
return;
|
|
412
852
|
}
|
|
413
853
|
|
|
@@ -416,17 +856,25 @@ class FormBuilder extends LitElement {
|
|
|
416
856
|
return;
|
|
417
857
|
}
|
|
418
858
|
|
|
859
|
+
await this.submitWithRetry(false);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async submitWithRetry(isRetry = false) {
|
|
419
863
|
try {
|
|
420
|
-
|
|
421
|
-
|
|
864
|
+
// Only set submitting on the first call, not on retry
|
|
865
|
+
if (!isRetry) {
|
|
866
|
+
this.submitting = true;
|
|
867
|
+
}
|
|
868
|
+
this.submissionError = null;
|
|
869
|
+
this.submissionStatus = null; // Clear previous messages
|
|
870
|
+
|
|
422
871
|
const body = {
|
|
423
872
|
username: this.decoded.sub,
|
|
424
873
|
formFname: this.fbmsFormFname,
|
|
425
874
|
formVersion: this.fbmsFormVersion,
|
|
426
875
|
timestamp: Date.now(),
|
|
427
|
-
answers: this.formData
|
|
876
|
+
answers: this.formData,
|
|
428
877
|
};
|
|
429
|
-
await delay(300);
|
|
430
878
|
|
|
431
879
|
const url = `${this.fbmsBaseUrl}/api/v1/submissions/${this.fbmsFormFname}`;
|
|
432
880
|
const headers = {
|
|
@@ -444,27 +892,121 @@ class FormBuilder extends LitElement {
|
|
|
444
892
|
body: JSON.stringify(body),
|
|
445
893
|
});
|
|
446
894
|
|
|
895
|
+
// Try to parse response body for messages (even on error)
|
|
896
|
+
let responseData = null;
|
|
897
|
+
try {
|
|
898
|
+
responseData = await response.json();
|
|
899
|
+
this.submissionStatus = responseData;
|
|
900
|
+
} catch (jsonErr) {
|
|
901
|
+
// Response might not be JSON
|
|
902
|
+
console.warn('Could not parse response as JSON:', jsonErr);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// Handle 403 - token may be stale
|
|
906
|
+
if (response.status === 403 && !isRetry) {
|
|
907
|
+
console.warn('Received 403, attempting to refresh token and retry...');
|
|
908
|
+
|
|
909
|
+
// Re-fetch token if OIDC URL is configured
|
|
910
|
+
if (this.oidcUrl) {
|
|
911
|
+
try {
|
|
912
|
+
await this.fetchToken();
|
|
913
|
+
console.warn('Token refreshed successfully, retrying submission...');
|
|
914
|
+
|
|
915
|
+
// Retry once with new token (submitting flag stays true)
|
|
916
|
+
return await this.submitWithRetry(true);
|
|
917
|
+
} catch (tokenError) {
|
|
918
|
+
console.error('Failed to refresh token:', tokenError);
|
|
919
|
+
// Fall through to handle the original 403 error
|
|
920
|
+
throw new Error('Authentication failed: Unable to refresh token');
|
|
921
|
+
}
|
|
922
|
+
} else {
|
|
923
|
+
console.warn('OIDC URL is not configured; cannot refresh token. Skipping retry.');
|
|
924
|
+
// Fall through to handle the 403 error normally
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
447
928
|
if (!response.ok) {
|
|
448
|
-
|
|
929
|
+
// Provide specific error for 403 after retry
|
|
930
|
+
if (response.status === 403 && isRetry) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
'Authorization failed: Access denied even after token refresh. You may not have permission to submit this form.'
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
// Use server error message if available
|
|
937
|
+
const errorMessage =
|
|
938
|
+
responseData?.messageHeader ||
|
|
939
|
+
responseData?.message ||
|
|
940
|
+
`Failed to submit form: ${response.statusText}`;
|
|
941
|
+
throw new Error(errorMessage);
|
|
449
942
|
}
|
|
450
943
|
|
|
451
|
-
//
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
944
|
+
// Check for form forwarding header (safely handle missing headers object)
|
|
945
|
+
const formForward = response.headers?.get ? response.headers.get('x-fbms-formforward') : null;
|
|
946
|
+
if (formForward) {
|
|
947
|
+
// eslint-disable-next-line no-console
|
|
948
|
+
console.info(`Form submitted successfully. Forwarding to next form: ${formForward}`);
|
|
949
|
+
this.fbmsFormFname = formForward;
|
|
950
|
+
|
|
951
|
+
// Keep success state and messages visible for the forwarded form
|
|
952
|
+
this.submitSuccess = true;
|
|
953
|
+
// Note: submissionStatus is preserved to show server messages on the next form
|
|
954
|
+
this.formCompleted = false;
|
|
955
|
+
|
|
956
|
+
// Re-initialize with the new form
|
|
957
|
+
this.loading = true;
|
|
958
|
+
try {
|
|
959
|
+
await this.initialize();
|
|
960
|
+
return; // Exit early, don't show success message for intermediate form
|
|
961
|
+
// Note: finally block will set submitting = false
|
|
962
|
+
} catch (forwardingError) {
|
|
963
|
+
console.error('Failed to load forwarded form:', forwardingError);
|
|
964
|
+
this.loading = false;
|
|
965
|
+
this.submissionError =
|
|
966
|
+
forwardingError?.message || 'Form was submitted, but loading the next form failed.';
|
|
967
|
+
}
|
|
968
|
+
}
|
|
457
969
|
|
|
458
|
-
//
|
|
459
|
-
this.
|
|
970
|
+
// Dispatch success event
|
|
971
|
+
this.dispatchEvent(
|
|
972
|
+
new CustomEvent('form-submit-success', {
|
|
973
|
+
detail: { data: body },
|
|
974
|
+
bubbles: true,
|
|
975
|
+
composed: true,
|
|
976
|
+
})
|
|
977
|
+
);
|
|
978
|
+
|
|
979
|
+
// No form forward - this is the final form completion
|
|
980
|
+
this.formCompleted = true;
|
|
981
|
+
this.submitSuccess = true;
|
|
982
|
+
this.submissionError = null;
|
|
983
|
+
this.initialFormData = this.deepClone(this.formData);
|
|
984
|
+
this.hasChanges = false;
|
|
985
|
+
|
|
986
|
+
await this.updateComplete;
|
|
987
|
+
|
|
988
|
+
// Scroll to success message
|
|
989
|
+
const successMsg = this.shadowRoot.querySelector('.status-message.success');
|
|
990
|
+
if (successMsg) {
|
|
991
|
+
successMsg.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
992
|
+
}
|
|
460
993
|
} catch (err) {
|
|
461
|
-
this.
|
|
462
|
-
|
|
463
|
-
this.dispatchEvent(
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
994
|
+
this.submissionError = err.message || 'Failed to submit form';
|
|
995
|
+
|
|
996
|
+
this.dispatchEvent(
|
|
997
|
+
new CustomEvent('form-submit-error', {
|
|
998
|
+
detail: { error: err.message },
|
|
999
|
+
bubbles: true,
|
|
1000
|
+
composed: true,
|
|
1001
|
+
})
|
|
1002
|
+
);
|
|
1003
|
+
|
|
1004
|
+
// Scroll to error message at top of form
|
|
1005
|
+
await this.updateComplete;
|
|
1006
|
+
const errorMsg = this.shadowRoot.querySelector('.status-message.error');
|
|
1007
|
+
if (errorMsg) {
|
|
1008
|
+
errorMsg.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
1009
|
+
}
|
|
468
1010
|
} finally {
|
|
469
1011
|
this.submitting = false;
|
|
470
1012
|
}
|
|
@@ -476,49 +1018,211 @@ class FormBuilder extends LitElement {
|
|
|
476
1018
|
this.requestUpdate();
|
|
477
1019
|
}
|
|
478
1020
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
const
|
|
1021
|
+
/**
|
|
1022
|
+
* Render a field - can be a simple input or a nested object
|
|
1023
|
+
*/
|
|
1024
|
+
renderField(fieldName, fieldSchema, basePath = '', depth = 0) {
|
|
1025
|
+
const MAX_DEPTH = 10;
|
|
1026
|
+
if (depth > MAX_DEPTH) {
|
|
1027
|
+
console.warn(`Schema nesting exceeds maximum depth of ${MAX_DEPTH}`);
|
|
1028
|
+
return html`<div class="error">Schema too deeply nested</div>`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const fieldPath = basePath ? `${basePath}.${fieldName}` : fieldName;
|
|
1032
|
+
|
|
1033
|
+
// Handle nested objects with properties
|
|
1034
|
+
if (fieldSchema.type === 'object' && fieldSchema.properties) {
|
|
1035
|
+
return this.renderNestedObject(fieldName, fieldSchema, basePath, depth);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
// Single-value enum - render as informational text only (title serves as the message)
|
|
1039
|
+
if (fieldSchema.enum && fieldSchema.enum.length === 1) {
|
|
1040
|
+
return html`
|
|
1041
|
+
<div class="form-group">
|
|
1042
|
+
<span class="info-label">${fieldSchema.title || fieldName}</span>
|
|
1043
|
+
${fieldSchema.description
|
|
1044
|
+
? html`<span class="description">${fieldSchema.description}</span>`
|
|
1045
|
+
: ''}
|
|
1046
|
+
</div>
|
|
1047
|
+
`;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// Regular field
|
|
1051
|
+
const value = this.getNestedValue(fieldPath);
|
|
1052
|
+
const error = this.fieldErrors[fieldPath];
|
|
1053
|
+
// For nested fields, check the parent schema's required array
|
|
1054
|
+
const parentSchema = basePath ? this.getSchemaAtPath(basePath) : this.schema;
|
|
1055
|
+
const required = parentSchema?.required?.includes(fieldName) ?? false;
|
|
1056
|
+
const uiSchemaPath = fieldPath.split('.');
|
|
1057
|
+
let uiOptions = this.uiSchema;
|
|
1058
|
+
for (const part of uiSchemaPath) {
|
|
1059
|
+
uiOptions = uiOptions?.[part];
|
|
1060
|
+
}
|
|
1061
|
+
uiOptions = uiOptions || {};
|
|
1062
|
+
|
|
1063
|
+
const widget = uiOptions['ui:widget'];
|
|
1064
|
+
const isGroupedInput = widget === 'radio' || widget === 'checkboxes';
|
|
484
1065
|
|
|
485
1066
|
return html`
|
|
486
1067
|
<div class="form-group">
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1068
|
+
${!isGroupedInput
|
|
1069
|
+
? html`
|
|
1070
|
+
<label class="${required ? 'required' : ''}" for="${fieldPath}">
|
|
1071
|
+
${fieldSchema.title || fieldName}
|
|
1072
|
+
</label>
|
|
1073
|
+
`
|
|
1074
|
+
: ''}
|
|
1075
|
+
${fieldSchema.description && !isGroupedInput
|
|
1076
|
+
? html` <span class="description">${fieldSchema.description}</span> `
|
|
1077
|
+
: ''}
|
|
1078
|
+
${this.renderInput(fieldPath, fieldSchema, value, uiOptions)}
|
|
1079
|
+
${error ? html` <span class="error-message">${error}</span> ` : ''}
|
|
1080
|
+
</div>
|
|
1081
|
+
`;
|
|
1082
|
+
}
|
|
494
1083
|
|
|
495
|
-
|
|
1084
|
+
/**
|
|
1085
|
+
* Render a nested object with its own properties
|
|
1086
|
+
*/
|
|
1087
|
+
renderNestedObject(fieldName, fieldSchema, basePath = '', depth = 0) {
|
|
1088
|
+
const fieldPath = basePath ? `${basePath}.${fieldName}` : fieldName;
|
|
496
1089
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1090
|
+
return html`
|
|
1091
|
+
<div class="nested-object">
|
|
1092
|
+
${fieldSchema.title
|
|
1093
|
+
? html`<div class="nested-object-title">${fieldSchema.title}</div>`
|
|
1094
|
+
: ''}
|
|
1095
|
+
${fieldSchema.description
|
|
1096
|
+
? html`<div class="nested-object-description">${fieldSchema.description}</div>`
|
|
1097
|
+
: ''}
|
|
1098
|
+
${Object.entries(fieldSchema.properties).map(([nestedFieldName, nestedFieldSchema]) =>
|
|
1099
|
+
this.renderField(nestedFieldName, nestedFieldSchema, fieldPath, depth + 1)
|
|
1100
|
+
)}
|
|
500
1101
|
</div>
|
|
501
1102
|
`;
|
|
502
1103
|
}
|
|
503
1104
|
|
|
504
|
-
renderInput(
|
|
505
|
-
const { type, enum: enumValues, format } = fieldSchema;
|
|
1105
|
+
renderInput(fieldPath, fieldSchema, value, uiOptions) {
|
|
1106
|
+
const { type, enum: enumValues, format, items } = fieldSchema;
|
|
1107
|
+
const widget = uiOptions['ui:widget'];
|
|
1108
|
+
const isInline = uiOptions['ui:options']?.inline;
|
|
506
1109
|
|
|
507
|
-
//
|
|
1110
|
+
// Single-value enum - no input needed, title/label already displays the message
|
|
1111
|
+
if (enumValues && enumValues.length === 1) {
|
|
1112
|
+
return html``;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Array of enums with checkboxes widget - render as checkboxes
|
|
1116
|
+
if (type === 'array' && items?.enum && widget === 'checkboxes') {
|
|
1117
|
+
const selectedValues = Array.isArray(value) ? value : [];
|
|
1118
|
+
const containerClass = isInline ? 'checkbox-group inline' : 'checkbox-group';
|
|
1119
|
+
|
|
1120
|
+
// Extract basePath and fieldName from fieldPath
|
|
1121
|
+
const pathParts = fieldPath.split('.');
|
|
1122
|
+
const fieldName = pathParts[pathParts.length - 1];
|
|
1123
|
+
const basePath = pathParts.slice(0, -1).join('.');
|
|
1124
|
+
|
|
1125
|
+
const parentSchema = basePath ? this.getSchemaAtPath(basePath) : this.schema;
|
|
1126
|
+
const isRequired = parentSchema?.required?.includes(fieldName) ?? false;
|
|
1127
|
+
|
|
1128
|
+
return html`
|
|
1129
|
+
<fieldset class="${containerClass}">
|
|
1130
|
+
<legend class="${isRequired ? 'required' : ''}">${fieldSchema.title || fieldName}</legend>
|
|
1131
|
+
${fieldSchema.description
|
|
1132
|
+
? html`<span class="description">${fieldSchema.description}</span>`
|
|
1133
|
+
: ''}
|
|
1134
|
+
${items.enum.map((opt) => {
|
|
1135
|
+
const sanitizedId = this.sanitizeId(`${fieldPath}-${opt}`);
|
|
1136
|
+
return html`
|
|
1137
|
+
<div class="checkbox-item">
|
|
1138
|
+
<input
|
|
1139
|
+
type="checkbox"
|
|
1140
|
+
id="${sanitizedId}"
|
|
1141
|
+
name="${fieldPath}"
|
|
1142
|
+
value="${opt}"
|
|
1143
|
+
.checked="${selectedValues.includes(opt)}"
|
|
1144
|
+
@change="${(e) => this.handleCheckboxArrayChange(fieldPath, opt, e)}"
|
|
1145
|
+
/>
|
|
1146
|
+
<label for="${sanitizedId}">${opt}</label>
|
|
1147
|
+
</div>
|
|
1148
|
+
`;
|
|
1149
|
+
})}
|
|
1150
|
+
</fieldset>
|
|
1151
|
+
`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// Array of enums without widget - render as multi-select dropdown (default)
|
|
1155
|
+
if (type === 'array' && items?.enum) {
|
|
1156
|
+
const selectedValues = Array.isArray(value) ? value : [];
|
|
1157
|
+
|
|
1158
|
+
return html`
|
|
1159
|
+
<select
|
|
1160
|
+
id="${fieldPath}"
|
|
1161
|
+
name="${fieldPath}"
|
|
1162
|
+
multiple
|
|
1163
|
+
size="5"
|
|
1164
|
+
@change="${(e) => this.handleMultiSelectChange(fieldPath, e)}"
|
|
1165
|
+
>
|
|
1166
|
+
${items.enum.map(
|
|
1167
|
+
(opt) => html`
|
|
1168
|
+
<option value="${opt}" ?selected="${selectedValues.includes(opt)}">${opt}</option>
|
|
1169
|
+
`
|
|
1170
|
+
)}
|
|
1171
|
+
</select>
|
|
1172
|
+
`;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Enum with radio widget - render as radio buttons
|
|
1176
|
+
if (enumValues && widget === 'radio') {
|
|
1177
|
+
const containerClass = isInline ? 'radio-group inline' : 'radio-group';
|
|
1178
|
+
|
|
1179
|
+
// Extract basePath and fieldName from fieldPath
|
|
1180
|
+
const pathParts = fieldPath.split('.');
|
|
1181
|
+
const fieldName = pathParts[pathParts.length - 1];
|
|
1182
|
+
const basePath = pathParts.slice(0, -1).join('.');
|
|
1183
|
+
|
|
1184
|
+
const parentSchema = basePath ? this.getSchemaAtPath(basePath) : this.schema;
|
|
1185
|
+
const isRequired = parentSchema?.required?.includes(fieldName) ?? false;
|
|
1186
|
+
|
|
1187
|
+
return html`
|
|
1188
|
+
<fieldset class="${containerClass}">
|
|
1189
|
+
<legend class="${isRequired ? 'required' : ''}">${fieldSchema.title || fieldName}</legend>
|
|
1190
|
+
${fieldSchema.description
|
|
1191
|
+
? html`<span class="description">${fieldSchema.description}</span>`
|
|
1192
|
+
: ''}
|
|
1193
|
+
${enumValues.map((opt) => {
|
|
1194
|
+
const sanitizedId = this.sanitizeId(`${fieldPath}-${opt}`);
|
|
1195
|
+
return html`
|
|
1196
|
+
<div class="radio-item">
|
|
1197
|
+
<input
|
|
1198
|
+
type="radio"
|
|
1199
|
+
id="${sanitizedId}"
|
|
1200
|
+
name="${fieldPath}"
|
|
1201
|
+
value="${opt}"
|
|
1202
|
+
.checked="${value === opt}"
|
|
1203
|
+
@change="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
1204
|
+
/>
|
|
1205
|
+
<label for="${sanitizedId}">${opt}</label>
|
|
1206
|
+
</div>
|
|
1207
|
+
`;
|
|
1208
|
+
})}
|
|
1209
|
+
</fieldset>
|
|
1210
|
+
`;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Enum - render as select (default)
|
|
508
1214
|
if (enumValues) {
|
|
509
1215
|
return html`
|
|
510
1216
|
<select
|
|
511
|
-
id="${
|
|
512
|
-
name="${
|
|
1217
|
+
id="${fieldPath}"
|
|
1218
|
+
name="${fieldPath}"
|
|
513
1219
|
.value="${value || ''}"
|
|
514
|
-
@change="${(e) => this.handleInputChange(
|
|
1220
|
+
@change="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
515
1221
|
>
|
|
516
1222
|
<option value="">-- Select --</option>
|
|
517
|
-
${enumValues.map(
|
|
518
|
-
<option value="${opt}" ?selected="${value === opt}">
|
|
519
|
-
|
|
520
|
-
</option>
|
|
521
|
-
`)}
|
|
1223
|
+
${enumValues.map(
|
|
1224
|
+
(opt) => html` <option value="${opt}" ?selected="${value === opt}">${opt}</option> `
|
|
1225
|
+
)}
|
|
522
1226
|
</select>
|
|
523
1227
|
`;
|
|
524
1228
|
}
|
|
@@ -529,12 +1233,12 @@ class FormBuilder extends LitElement {
|
|
|
529
1233
|
<div class="checkbox-item">
|
|
530
1234
|
<input
|
|
531
1235
|
type="checkbox"
|
|
532
|
-
id="${
|
|
533
|
-
name="${
|
|
1236
|
+
id="${fieldPath}"
|
|
1237
|
+
name="${fieldPath}"
|
|
534
1238
|
.checked="${!!value}"
|
|
535
|
-
@change="${(e) => this.handleInputChange(
|
|
1239
|
+
@change="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
536
1240
|
/>
|
|
537
|
-
<label for="${
|
|
1241
|
+
<label for="${fieldPath}">${fieldSchema.title || fieldPath.split('.').pop()}</label>
|
|
538
1242
|
</div>
|
|
539
1243
|
`;
|
|
540
1244
|
}
|
|
@@ -545,10 +1249,10 @@ class FormBuilder extends LitElement {
|
|
|
545
1249
|
return html`
|
|
546
1250
|
<input
|
|
547
1251
|
type="email"
|
|
548
|
-
id="${
|
|
549
|
-
name="${
|
|
1252
|
+
id="${fieldPath}"
|
|
1253
|
+
name="${fieldPath}"
|
|
550
1254
|
.value="${value || ''}"
|
|
551
|
-
@input="${(e) => this.handleInputChange(
|
|
1255
|
+
@input="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
552
1256
|
/>
|
|
553
1257
|
`;
|
|
554
1258
|
}
|
|
@@ -557,10 +1261,10 @@ class FormBuilder extends LitElement {
|
|
|
557
1261
|
return html`
|
|
558
1262
|
<input
|
|
559
1263
|
type="date"
|
|
560
|
-
id="${
|
|
561
|
-
name="${
|
|
1264
|
+
id="${fieldPath}"
|
|
1265
|
+
name="${fieldPath}"
|
|
562
1266
|
.value="${value || ''}"
|
|
563
|
-
@input="${(e) => this.handleInputChange(
|
|
1267
|
+
@input="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
564
1268
|
/>
|
|
565
1269
|
`;
|
|
566
1270
|
}
|
|
@@ -568,10 +1272,10 @@ class FormBuilder extends LitElement {
|
|
|
568
1272
|
if (uiOptions['ui:widget'] === 'textarea') {
|
|
569
1273
|
return html`
|
|
570
1274
|
<textarea
|
|
571
|
-
id="${
|
|
572
|
-
name="${
|
|
1275
|
+
id="${fieldPath}"
|
|
1276
|
+
name="${fieldPath}"
|
|
573
1277
|
.value="${value || ''}"
|
|
574
|
-
@input="${(e) => this.handleInputChange(
|
|
1278
|
+
@input="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
575
1279
|
></textarea>
|
|
576
1280
|
`;
|
|
577
1281
|
}
|
|
@@ -580,10 +1284,10 @@ class FormBuilder extends LitElement {
|
|
|
580
1284
|
return html`
|
|
581
1285
|
<input
|
|
582
1286
|
type="text"
|
|
583
|
-
id="${
|
|
584
|
-
name="${
|
|
1287
|
+
id="${fieldPath}"
|
|
1288
|
+
name="${fieldPath}"
|
|
585
1289
|
.value="${value || ''}"
|
|
586
|
-
@input="${(e) => this.handleInputChange(
|
|
1290
|
+
@input="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
587
1291
|
/>
|
|
588
1292
|
`;
|
|
589
1293
|
}
|
|
@@ -593,11 +1297,11 @@ class FormBuilder extends LitElement {
|
|
|
593
1297
|
return html`
|
|
594
1298
|
<input
|
|
595
1299
|
type="number"
|
|
596
|
-
id="${
|
|
597
|
-
name="${
|
|
1300
|
+
id="${fieldPath}"
|
|
1301
|
+
name="${fieldPath}"
|
|
598
1302
|
.value="${value || ''}"
|
|
599
1303
|
step="${type === 'integer' ? '1' : 'any'}"
|
|
600
|
-
@input="${(e) => this.handleInputChange(
|
|
1304
|
+
@input="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
601
1305
|
/>
|
|
602
1306
|
`;
|
|
603
1307
|
}
|
|
@@ -606,29 +1310,27 @@ class FormBuilder extends LitElement {
|
|
|
606
1310
|
return html`
|
|
607
1311
|
<input
|
|
608
1312
|
type="text"
|
|
609
|
-
id="${
|
|
610
|
-
name="${
|
|
1313
|
+
id="${fieldPath}"
|
|
1314
|
+
name="${fieldPath}"
|
|
611
1315
|
.value="${value || ''}"
|
|
612
|
-
@input="${(e) => this.handleInputChange(
|
|
1316
|
+
@input="${(e) => this.handleInputChange(fieldPath, e)}"
|
|
613
1317
|
/>
|
|
614
1318
|
`;
|
|
615
1319
|
}
|
|
616
1320
|
|
|
617
1321
|
render() {
|
|
618
|
-
if (this.
|
|
1322
|
+
if (this.error) {
|
|
619
1323
|
return html`
|
|
620
1324
|
<div class="container">
|
|
621
|
-
<div class="
|
|
1325
|
+
<div class="error"><strong>Error:</strong> ${this.error}</div>
|
|
622
1326
|
</div>
|
|
623
1327
|
`;
|
|
624
1328
|
}
|
|
625
1329
|
|
|
626
|
-
if (this.
|
|
1330
|
+
if (this.loading) {
|
|
627
1331
|
return html`
|
|
628
1332
|
<div class="container">
|
|
629
|
-
<div class="
|
|
630
|
-
<strong>Error:</strong> ${this.error}
|
|
631
|
-
</div>
|
|
1333
|
+
<div class="loading">Loading form...</div>
|
|
632
1334
|
</div>
|
|
633
1335
|
`;
|
|
634
1336
|
}
|
|
@@ -641,30 +1343,106 @@ class FormBuilder extends LitElement {
|
|
|
641
1343
|
`;
|
|
642
1344
|
}
|
|
643
1345
|
|
|
644
|
-
|
|
645
|
-
|
|
1346
|
+
// NEW: Success-only view when form is completed
|
|
1347
|
+
if (this.formCompleted) {
|
|
1348
|
+
return html`
|
|
1349
|
+
${this.customStyles
|
|
1350
|
+
? html`<style>
|
|
1351
|
+
${this.customStyles}
|
|
1352
|
+
</style>`
|
|
1353
|
+
: ''}
|
|
646
1354
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
1355
|
+
<div class="container">
|
|
1356
|
+
<div class="status-message success">
|
|
1357
|
+
<h2>✓ Form submitted successfully!</h2>
|
|
1358
|
+
${this.submissionStatus?.messages?.length > 0
|
|
1359
|
+
? html`
|
|
1360
|
+
<ul>
|
|
1361
|
+
${this.submissionStatus.messages.map((msg) => html`<li>${msg}</li>`)}
|
|
1362
|
+
</ul>
|
|
1363
|
+
`
|
|
1364
|
+
: ''}
|
|
1365
|
+
</div>
|
|
1366
|
+
</div>
|
|
1367
|
+
`;
|
|
1368
|
+
}
|
|
651
1369
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
)}
|
|
1370
|
+
// Regular form view (rest of existing render code)
|
|
1371
|
+
const hasFields = Object.keys(this.schema.properties).length > 0;
|
|
655
1372
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1373
|
+
return html`
|
|
1374
|
+
${this.customStyles
|
|
1375
|
+
? html`<style>
|
|
1376
|
+
${this.customStyles}
|
|
1377
|
+
</style>`
|
|
1378
|
+
: ''}
|
|
1379
|
+
|
|
1380
|
+
<div class="container">
|
|
1381
|
+
${this.submitSuccess
|
|
1382
|
+
? html`
|
|
1383
|
+
<div class="status-message success">
|
|
1384
|
+
✓ Your form was successfully submitted.
|
|
1385
|
+
${this.submissionStatus?.messages?.length > 0
|
|
1386
|
+
? html`
|
|
1387
|
+
<ul>
|
|
1388
|
+
${this.submissionStatus.messages.map((msg) => html`<li>${msg}</li>`)}
|
|
1389
|
+
</ul>
|
|
1390
|
+
`
|
|
1391
|
+
: ''}
|
|
1392
|
+
</div>
|
|
1393
|
+
`
|
|
1394
|
+
: ''}
|
|
1395
|
+
${hasFields
|
|
1396
|
+
? html`
|
|
1397
|
+
<form @submit="${this.handleSubmit}" class="${this.submitting ? 'submitting' : ''}">
|
|
1398
|
+
${this.schema.title ? html`<h2>${this.schema.title}</h2>` : ''}
|
|
1399
|
+
${this.schema.description ? html`<p>${this.schema.description}</p>` : ''}
|
|
1400
|
+
${this.validationFailed
|
|
1401
|
+
? html`
|
|
1402
|
+
<div class="status-message validation-error">
|
|
1403
|
+
⚠ Please correct the errors below before submitting.
|
|
1404
|
+
</div>
|
|
1405
|
+
`
|
|
1406
|
+
: ''}
|
|
1407
|
+
${this.submissionError
|
|
1408
|
+
? html`
|
|
1409
|
+
<div class="status-message error">
|
|
1410
|
+
<strong>Error:</strong> ${this.submissionError}
|
|
1411
|
+
${this.submissionStatus?.messages?.length > 0
|
|
1412
|
+
? html`
|
|
1413
|
+
<ul>
|
|
1414
|
+
${this.submissionStatus.messages.map(
|
|
1415
|
+
(msg) => html`<li>${msg}</li>`
|
|
1416
|
+
)}
|
|
1417
|
+
</ul>
|
|
1418
|
+
`
|
|
1419
|
+
: ''}
|
|
1420
|
+
</div>
|
|
1421
|
+
`
|
|
1422
|
+
: ''}
|
|
1423
|
+
${Object.entries(this.schema.properties).map(([fieldName, fieldSchema]) =>
|
|
1424
|
+
this.renderField(fieldName, fieldSchema)
|
|
1425
|
+
)}
|
|
1426
|
+
|
|
1427
|
+
<div class="buttons">
|
|
1428
|
+
<button type="submit" ?disabled="${this.submitting}">
|
|
1429
|
+
<span class="button-content">
|
|
1430
|
+
${this.submitting ? html`<span class="spinner"></span>` : ''}
|
|
1431
|
+
${this.submitting ? 'Submitting...' : 'Submit'}
|
|
1432
|
+
</span>
|
|
1433
|
+
</button>
|
|
1434
|
+
<button type="button" @click="${this.handleReset}" ?disabled="${this.submitting}">
|
|
1435
|
+
Reset
|
|
1436
|
+
</button>
|
|
1437
|
+
</div>
|
|
1438
|
+
</form>
|
|
1439
|
+
`
|
|
1440
|
+
: html`
|
|
1441
|
+
<div class="info-only">
|
|
1442
|
+
${this.schema.title ? html`<h2>${this.schema.title}</h2>` : ''}
|
|
1443
|
+
${this.schema.description ? html`<p>${this.schema.description}</p>` : ''}
|
|
1444
|
+
</div>
|
|
1445
|
+
`}
|
|
668
1446
|
</div>
|
|
669
1447
|
`;
|
|
670
1448
|
}
|