@uportal/form-builder 1.3.2 → 2.1.0

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,1451 @@
1
+ import { LitElement, html, css } from 'lit';
2
+ import { jwtDecode } from 'jwt-decode';
3
+
4
+ /**
5
+ * Dynamic Form Builder Web Component
6
+ * Fetches JSON schema and form data, then renders a dynamic form
7
+ *
8
+ * @element form-builder
9
+ *
10
+ * @attr {string} fbms-base-url - Base URL of the form builder microservice
11
+ * @attr {string} fbms-form-fname - Form name to fetch
12
+ * @attr {string} oidc-url - OpenID Connect URL for authentication
13
+ * @attr {string} styles - Optional custom CSS styles
14
+ */
15
+ class FormBuilder extends LitElement {
16
+ static properties = {
17
+ fbmsBaseUrl: { type: String, attribute: 'fbms-base-url' },
18
+ fbmsFormFname: { type: String, attribute: 'fbms-form-fname' },
19
+ oidcUrl: { type: String, attribute: 'oidc-url' },
20
+ customStyles: { type: String, attribute: 'styles' },
21
+
22
+ // Internal state
23
+ schema: { type: Object, state: true },
24
+ _formData: { type: Object, state: true },
25
+ uiSchema: { type: Object, state: true },
26
+ fbmsFormVersion: { type: String, state: true },
27
+ loading: { type: Boolean, state: true },
28
+ submitting: { type: Boolean, state: true },
29
+ error: { type: String, state: true },
30
+ token: { type: String, state: true },
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 },
39
+ };
40
+
41
+ static styles = css`
42
+ :host {
43
+ display: block;
44
+ font-family:
45
+ -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
46
+ }
47
+
48
+ .container {
49
+ max-width: 800px;
50
+ margin: 0 auto;
51
+ padding: 20px;
52
+ }
53
+
54
+ .loading {
55
+ text-align: center;
56
+ padding: 40px;
57
+ color: #666;
58
+ }
59
+
60
+ .error {
61
+ background-color: #fee;
62
+ border: 1px solid #fcc;
63
+ border-radius: 4px;
64
+ padding: 15px;
65
+ margin: 20px 0;
66
+ color: #c00;
67
+ }
68
+
69
+ form {
70
+ display: flex;
71
+ flex-direction: column;
72
+ gap: 20px;
73
+ }
74
+
75
+ .form-group {
76
+ display: flex;
77
+ flex-direction: column;
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;
99
+ }
100
+
101
+ label {
102
+ font-weight: 500;
103
+ color: #333;
104
+ }
105
+
106
+ .required::after {
107
+ content: ' *';
108
+ color: #c00;
109
+ }
110
+
111
+ .description {
112
+ font-size: 0.875rem;
113
+ color: #666;
114
+ margin-top: 4px;
115
+ }
116
+
117
+ input[type='text'],
118
+ input[type='email'],
119
+ input[type='number'],
120
+ input[type='date'],
121
+ input[type='tel'],
122
+ textarea,
123
+ select {
124
+ padding: 8px 12px;
125
+ border: 1px solid #ccc;
126
+ border-radius: 4px;
127
+ font-size: 1rem;
128
+ font-family: inherit;
129
+ width: 100%;
130
+ box-sizing: border-box;
131
+ }
132
+
133
+ input:focus,
134
+ textarea:focus,
135
+ select:focus {
136
+ outline: none;
137
+ border-color: #0066cc;
138
+ box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
139
+ }
140
+
141
+ select[multiple] {
142
+ min-height: 120px;
143
+ padding: 4px;
144
+ }
145
+
146
+ select[multiple] option {
147
+ padding: 4px 8px;
148
+ }
149
+
150
+ textarea {
151
+ min-height: 100px;
152
+ resize: vertical;
153
+ }
154
+
155
+ input[type='checkbox'],
156
+ input[type='radio'] {
157
+ margin-right: 8px;
158
+ }
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
+
175
+ .checkbox-group,
176
+ .radio-group {
177
+ display: flex;
178
+ flex-direction: column;
179
+ gap: 8px;
180
+ }
181
+
182
+ .checkbox-item,
183
+ .radio-item {
184
+ display: flex;
185
+ align-items: center;
186
+ }
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
+
200
+ .error-message {
201
+ color: #c00;
202
+ font-size: 0.875rem;
203
+ margin-top: 4px;
204
+ }
205
+
206
+ .buttons {
207
+ display: flex;
208
+ gap: 12px;
209
+ margin-top: 20px;
210
+ }
211
+
212
+ button {
213
+ padding: 10px 20px;
214
+ border: none;
215
+ border-radius: 4px;
216
+ font-size: 1rem;
217
+ cursor: pointer;
218
+ font-family: inherit;
219
+ transition: background-color 0.2s;
220
+ }
221
+
222
+ button[type='submit'] {
223
+ background-color: #0066cc;
224
+ color: white;
225
+ }
226
+
227
+ button[type='submit']:hover {
228
+ background-color: #0052a3;
229
+ }
230
+
231
+ button[type='button'] {
232
+ background-color: #c0c0c0;
233
+ color: #333;
234
+ }
235
+
236
+ button[type='button']:hover {
237
+ background-color: #e0e0e0;
238
+ }
239
+
240
+ button:disabled {
241
+ opacity: 0.5;
242
+ cursor: not-allowed;
243
+ }
244
+
245
+ .spinner {
246
+ display: inline-block;
247
+ width: 1em;
248
+ height: 1em;
249
+ border: 2px solid rgba(255, 255, 255, 0.3);
250
+ border-radius: 50%;
251
+ border-top-color: white;
252
+ animation: spin 0.8s linear infinite;
253
+ margin-right: 8px;
254
+ vertical-align: middle;
255
+ }
256
+
257
+ @keyframes spin {
258
+ to {
259
+ transform: rotate(360deg);
260
+ }
261
+ }
262
+
263
+ .button-content {
264
+ display: inline-flex;
265
+ align-items: center;
266
+ justify-content: center;
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
+ }
326
+ `;
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
+
360
+ constructor() {
361
+ super();
362
+ this.loading = true;
363
+ this.submitting = false;
364
+ this.error = null;
365
+ this.schema = null;
366
+ this._formData = {};
367
+ this.uiSchema = null;
368
+ this.fbmsFormVersion = null;
369
+ this.token = null;
370
+ this.decoded = { sub: 'unknown' };
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;
379
+ }
380
+
381
+ async connectedCallback() {
382
+ super.connectedCallback();
383
+ await this.initialize();
384
+ }
385
+
386
+ async initialize() {
387
+ try {
388
+ this.loading = true;
389
+ this.error = null;
390
+
391
+ // Fetch OIDC token if URL provided
392
+ if (this.oidcUrl) {
393
+ await this.fetchToken();
394
+ }
395
+
396
+ // Fetch form schema and data
397
+ await Promise.all([this.fetchSchema(), this.fetchFormData()]);
398
+
399
+ this.loading = false;
400
+ } catch (err) {
401
+ this.error = err.message || 'Failed to initialize form';
402
+ this.loading = false;
403
+ }
404
+ }
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
+
480
+ async fetchToken() {
481
+ try {
482
+ const response = await fetch(this.oidcUrl, {
483
+ credentials: 'include',
484
+ });
485
+
486
+ if (!response.ok) {
487
+ throw new Error('Failed to authenticate');
488
+ }
489
+
490
+ const data = await response.text();
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
+ }
499
+ } catch (err) {
500
+ console.error('Token fetch error:', err);
501
+ throw new Error('Authentication failed');
502
+ }
503
+ }
504
+
505
+ async fetchSchema() {
506
+ const url = `${this.fbmsBaseUrl}/api/v1/forms/${this.fbmsFormFname}`;
507
+ const headers = {
508
+ 'content-type': 'application/jwt',
509
+ };
510
+
511
+ if (this.token) {
512
+ headers['Authorization'] = `Bearer ${this.token}`;
513
+ }
514
+
515
+ const response = await fetch(url, {
516
+ credentials: 'same-origin',
517
+ headers,
518
+ });
519
+
520
+ if (!response.ok) {
521
+ throw new Error(`Failed to fetch schema: ${response.statusText}`);
522
+ }
523
+
524
+ const data = await response.json();
525
+ this.fbmsFormVersion = data.version;
526
+ this.schema = data.schema || data;
527
+ this.uiSchema = data.metadata;
528
+ }
529
+
530
+ async fetchFormData() {
531
+ const url = `${this.fbmsBaseUrl}/api/v1/submissions/${this.fbmsFormFname}?safarifix=${Math.random()}`;
532
+ const headers = {
533
+ 'content-type': 'application/jwt',
534
+ };
535
+
536
+ if (this.token) {
537
+ headers['Authorization'] = `Bearer ${this.token}`;
538
+ }
539
+
540
+ try {
541
+ const response = await fetch(url, {
542
+ credentials: 'same-origin',
543
+ headers,
544
+ });
545
+
546
+ if (response.ok) {
547
+ const payload = await response.json();
548
+ this._formData = payload?.answers ?? {}; // Use private property
549
+ this.initialFormData = this.deepClone(this._formData); // Use deepClone
550
+ } else {
551
+ this._formData = {};
552
+ this.initialFormData = {};
553
+ }
554
+ this.hasChanges = false;
555
+ this.requestUpdate();
556
+ } catch (err) {
557
+ // Non-critical error
558
+ console.warn('Could not fetch form data:', err);
559
+ this._formData = {};
560
+ this.initialFormData = {};
561
+ this.hasChanges = false;
562
+ this.requestUpdate();
563
+ }
564
+ }
565
+
566
+ updateStateFlags() {
567
+ // Clear status messages when user makes changes
568
+ this.submitSuccess = false;
569
+ this.validationFailed = false;
570
+ this.submissionError = null;
571
+
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);
676
+
677
+ // Clear field error on change
678
+ if (this.fieldErrors[fieldPath]) {
679
+ this.fieldErrors = { ...this.fieldErrors };
680
+ delete this.fieldErrors[fieldPath];
681
+ }
682
+
683
+ this.updateStateFlags();
684
+ }
685
+
686
+ handleArrayChange(fieldPath, index, event) {
687
+ const currentArray = this.getNestedValue(fieldPath) || [];
688
+ const newArray = [...currentArray];
689
+ newArray[index] = event.target.value;
690
+ this.setNestedValue(fieldPath, newArray);
691
+
692
+ this.updateStateFlags();
693
+ }
694
+
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
+
744
+ const errors = {};
745
+
746
+ // Check required fields
747
+ required.forEach((fieldName) => {
748
+ const fieldPath = basePath ? `${basePath}.${fieldName}` : fieldName;
749
+ const value = this.getNestedValue(fieldPath);
750
+ if (value === undefined || value === null || value === '') {
751
+ const customMsg = this.getCustomErrorMessage(fieldPath, 'required');
752
+ errors[fieldPath] = customMsg || 'This field is required';
753
+ }
754
+ });
755
+
756
+ // Type validation
757
+ Object.entries(properties).forEach(([fieldName, fieldSchema]) => {
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
+ }
772
+
773
+ if (value !== undefined && value !== null && value !== '') {
774
+ // Email validation
775
+ if (fieldSchema.format === 'email') {
776
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
777
+ if (!emailRegex.test(value)) {
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';
792
+ }
793
+ }
794
+
795
+ // Number validation
796
+ if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') {
797
+ const num = Number(value);
798
+ if (isNaN(num)) {
799
+ const customMsg = this.getCustomErrorMessage(fieldPath, 'type');
800
+ errors[fieldPath] = customMsg || 'Must be a number';
801
+ } else {
802
+ if (fieldSchema.minimum !== undefined && num < fieldSchema.minimum) {
803
+ const customMsg = this.getCustomErrorMessage(fieldPath, 'minimum');
804
+ errors[fieldPath] = customMsg || `Must be at least ${fieldSchema.minimum}`;
805
+ }
806
+ if (fieldSchema.maximum !== undefined && num > fieldSchema.maximum) {
807
+ const customMsg = this.getCustomErrorMessage(fieldPath, 'maximum');
808
+ errors[fieldPath] = customMsg || `Must be at most ${fieldSchema.maximum}`;
809
+ }
810
+ }
811
+ }
812
+
813
+ // String length validation
814
+ if (fieldSchema.type === 'string') {
815
+ if (fieldSchema.minLength && value.length < fieldSchema.minLength) {
816
+ const customMsg = this.getCustomErrorMessage(fieldPath, 'minLength');
817
+ errors[fieldPath] = customMsg || `Must be at least ${fieldSchema.minLength} characters`;
818
+ }
819
+ if (fieldSchema.maxLength && value.length > fieldSchema.maxLength) {
820
+ const customMsg = this.getCustomErrorMessage(fieldPath, 'maxLength');
821
+ errors[fieldPath] = customMsg || `Must be at most ${fieldSchema.maxLength} characters`;
822
+ }
823
+ }
824
+ }
825
+ });
826
+
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;
834
+ }
835
+
836
+ async handleSubmit(event) {
837
+ event.preventDefault();
838
+
839
+ // Clear previous status messages
840
+ this.submitSuccess = false;
841
+ this.validationFailed = false;
842
+ if (!this.validateForm()) {
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
+ }
851
+ return;
852
+ }
853
+
854
+ // Prevent double-submission
855
+ if (this.submitting) {
856
+ return;
857
+ }
858
+
859
+ await this.submitWithRetry(false);
860
+ }
861
+
862
+ async submitWithRetry(isRetry = false) {
863
+ try {
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
+
871
+ const body = {
872
+ username: this.decoded.sub,
873
+ formFname: this.fbmsFormFname,
874
+ formVersion: this.fbmsFormVersion,
875
+ timestamp: Date.now(),
876
+ answers: this.formData,
877
+ };
878
+
879
+ const url = `${this.fbmsBaseUrl}/api/v1/submissions/${this.fbmsFormFname}`;
880
+ const headers = {
881
+ 'content-type': 'application/json',
882
+ };
883
+
884
+ if (this.token) {
885
+ headers['Authorization'] = `Bearer ${this.token}`;
886
+ }
887
+
888
+ const response = await fetch(url, {
889
+ method: 'POST',
890
+ credentials: 'same-origin',
891
+ headers,
892
+ body: JSON.stringify(body),
893
+ });
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
+
928
+ if (!response.ok) {
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);
942
+ }
943
+
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
+ }
969
+
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
+ }
993
+ } catch (err) {
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
+ }
1010
+ } finally {
1011
+ this.submitting = false;
1012
+ }
1013
+ }
1014
+
1015
+ handleReset() {
1016
+ this.formData = {};
1017
+ this.fieldErrors = {};
1018
+ this.requestUpdate();
1019
+ }
1020
+
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';
1065
+
1066
+ return html`
1067
+ <div class="form-group">
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
+ }
1083
+
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;
1089
+
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
+ )}
1101
+ </div>
1102
+ `;
1103
+ }
1104
+
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;
1109
+
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)
1214
+ if (enumValues) {
1215
+ return html`
1216
+ <select
1217
+ id="${fieldPath}"
1218
+ name="${fieldPath}"
1219
+ .value="${value || ''}"
1220
+ @change="${(e) => this.handleInputChange(fieldPath, e)}"
1221
+ >
1222
+ <option value="">-- Select --</option>
1223
+ ${enumValues.map(
1224
+ (opt) => html` <option value="${opt}" ?selected="${value === opt}">${opt}</option> `
1225
+ )}
1226
+ </select>
1227
+ `;
1228
+ }
1229
+
1230
+ // Boolean - render as checkbox
1231
+ if (type === 'boolean') {
1232
+ return html`
1233
+ <div class="checkbox-item">
1234
+ <input
1235
+ type="checkbox"
1236
+ id="${fieldPath}"
1237
+ name="${fieldPath}"
1238
+ .checked="${!!value}"
1239
+ @change="${(e) => this.handleInputChange(fieldPath, e)}"
1240
+ />
1241
+ <label for="${fieldPath}">${fieldSchema.title || fieldPath.split('.').pop()}</label>
1242
+ </div>
1243
+ `;
1244
+ }
1245
+
1246
+ // String with format
1247
+ if (type === 'string') {
1248
+ if (format === 'email') {
1249
+ return html`
1250
+ <input
1251
+ type="email"
1252
+ id="${fieldPath}"
1253
+ name="${fieldPath}"
1254
+ .value="${value || ''}"
1255
+ @input="${(e) => this.handleInputChange(fieldPath, e)}"
1256
+ />
1257
+ `;
1258
+ }
1259
+
1260
+ if (format === 'date') {
1261
+ return html`
1262
+ <input
1263
+ type="date"
1264
+ id="${fieldPath}"
1265
+ name="${fieldPath}"
1266
+ .value="${value || ''}"
1267
+ @input="${(e) => this.handleInputChange(fieldPath, e)}"
1268
+ />
1269
+ `;
1270
+ }
1271
+
1272
+ if (uiOptions['ui:widget'] === 'textarea') {
1273
+ return html`
1274
+ <textarea
1275
+ id="${fieldPath}"
1276
+ name="${fieldPath}"
1277
+ .value="${value || ''}"
1278
+ @input="${(e) => this.handleInputChange(fieldPath, e)}"
1279
+ ></textarea>
1280
+ `;
1281
+ }
1282
+
1283
+ // Default text input
1284
+ return html`
1285
+ <input
1286
+ type="text"
1287
+ id="${fieldPath}"
1288
+ name="${fieldPath}"
1289
+ .value="${value || ''}"
1290
+ @input="${(e) => this.handleInputChange(fieldPath, e)}"
1291
+ />
1292
+ `;
1293
+ }
1294
+
1295
+ // Number
1296
+ if (type === 'number' || type === 'integer') {
1297
+ return html`
1298
+ <input
1299
+ type="number"
1300
+ id="${fieldPath}"
1301
+ name="${fieldPath}"
1302
+ .value="${value || ''}"
1303
+ step="${type === 'integer' ? '1' : 'any'}"
1304
+ @input="${(e) => this.handleInputChange(fieldPath, e)}"
1305
+ />
1306
+ `;
1307
+ }
1308
+
1309
+ // Fallback
1310
+ return html`
1311
+ <input
1312
+ type="text"
1313
+ id="${fieldPath}"
1314
+ name="${fieldPath}"
1315
+ .value="${value || ''}"
1316
+ @input="${(e) => this.handleInputChange(fieldPath, e)}"
1317
+ />
1318
+ `;
1319
+ }
1320
+
1321
+ render() {
1322
+ if (this.error) {
1323
+ return html`
1324
+ <div class="container">
1325
+ <div class="error"><strong>Error:</strong> ${this.error}</div>
1326
+ </div>
1327
+ `;
1328
+ }
1329
+
1330
+ if (this.loading) {
1331
+ return html`
1332
+ <div class="container">
1333
+ <div class="loading">Loading form...</div>
1334
+ </div>
1335
+ `;
1336
+ }
1337
+
1338
+ if (!this.schema || !this.schema.properties) {
1339
+ return html`
1340
+ <div class="container">
1341
+ <div class="error">Invalid form schema</div>
1342
+ </div>
1343
+ `;
1344
+ }
1345
+
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
+ : ''}
1354
+
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
+ }
1369
+
1370
+ // Regular form view (rest of existing render code)
1371
+ const hasFields = Object.keys(this.schema.properties).length > 0;
1372
+
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 || !this.hasChanges}">
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
+ `}
1446
+ </div>
1447
+ `;
1448
+ }
1449
+ }
1450
+
1451
+ customElements.define('form-builder', FormBuilder);