@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.
@@ -1,16 +1,12 @@
1
1
  import { LitElement, html, css } from 'lit';
2
- import decode from 'jwt-decode';
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
- formData: { type: Object, state: true },
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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
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: 8px;
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="text"],
94
- input[type="email"],
95
- input[type="number"],
96
- input[type="date"],
97
- input[type="tel"],
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="checkbox"],
123
- input[type="radio"] {
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="submit"] {
222
+ button[type='submit'] {
163
223
  background-color: #0066cc;
164
224
  color: white;
165
225
  }
166
226
 
167
- button[type="submit"]:hover {
227
+ button[type='submit']:hover {
168
228
  background-color: #0052a3;
169
229
  }
170
230
 
171
- button[type="button"] {
231
+ button[type='button'] {
172
232
  background-color: #c0c0c0;
173
233
  color: #333;
174
234
  }
175
235
 
176
- button[type="button"]:hover {
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 { transform: rotate(360deg); }
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.formData = {};
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
- this.decoded = decode(this.token);
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.formData = payload.answers;
548
+ this._formData = payload?.answers ?? {}; // Use private property
549
+ this.initialFormData = this.deepClone(this._formData); // Use deepClone
313
550
  } else {
314
- // It's OK if there's no existing data
315
- this.formData = {};
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.formData = {};
559
+ this._formData = {};
560
+ this.initialFormData = {};
561
+ this.hasChanges = false;
562
+ this.requestUpdate();
321
563
  }
322
564
  }
323
565
 
324
- handleInputChange(fieldName, event) {
325
- const { type, value, checked } = event.target;
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
- this.formData = {
328
- ...this.formData,
329
- [fieldName]: type === 'checkbox' ? checked : value,
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[fieldName]) {
678
+ if (this.fieldErrors[fieldPath]) {
334
679
  this.fieldErrors = { ...this.fieldErrors };
335
- delete this.fieldErrors[fieldName];
680
+ delete this.fieldErrors[fieldPath];
336
681
  }
682
+
683
+ this.updateStateFlags();
337
684
  }
338
685
 
339
- handleArrayChange(fieldName, index, event) {
340
- const currentArray = this.formData[fieldName] || [];
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.formData = {
345
- ...this.formData,
346
- [fieldName]: newArray,
347
- };
692
+ this.updateStateFlags();
348
693
  }
349
694
 
350
- validateForm() {
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 value = this.formData[fieldName];
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
- errors[fieldName] = 'This field is required';
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 value = this.formData[fieldName];
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
- errors[fieldName] = 'Invalid email address';
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
- errors[fieldName] = 'Must be a number';
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
- errors[fieldName] = `Must be at least ${fieldSchema.minimum}`;
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
- errors[fieldName] = `Must be at most ${fieldSchema.maximum}`;
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
- errors[fieldName] = `Must be at least ${fieldSchema.minLength} characters`;
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
- errors[fieldName] = `Must be at most ${fieldSchema.maxLength} characters`;
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
- this.fieldErrors = errors;
403
- return Object.keys(errors).length === 0;
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.requestUpdate();
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
- this.submitting = true;
421
- this.error = null;
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
- throw new Error(`Failed to submit form: ${response.statusText}`);
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
- // Dispatch success event
452
- this.dispatchEvent(new CustomEvent('form-submit-success', {
453
- detail: { data: body },
454
- bubbles: true,
455
- composed: true,
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
- // Optional: Reset or show success message
459
- this.error = null;
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.error = err.message || 'Failed to submit form';
462
-
463
- this.dispatchEvent(new CustomEvent('form-submit-error', {
464
- detail: { error: err.message },
465
- bubbles: true,
466
- composed: true,
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
- renderField(fieldName, fieldSchema) {
480
- const value = this.formData[fieldName];
481
- const error = this.fieldErrors[fieldName];
482
- const required = this.schema.required?.includes(fieldName);
483
- const uiOptions = this.uiSchema?.[fieldName] || {};
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
- <label class="${required ? 'required' : ''}" for="${fieldName}">
488
- ${fieldSchema.title || fieldName}
489
- </label>
490
-
491
- ${fieldSchema.description ? html`
492
- <span class="description">${fieldSchema.description}</span>
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
- ${this.renderInput(fieldName, fieldSchema, value, uiOptions)}
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
- ${error ? html`
498
- <span class="error-message">${error}</span>
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(fieldName, fieldSchema, value, uiOptions) {
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
- // Enum - render as select
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="${fieldName}"
512
- name="${fieldName}"
1217
+ id="${fieldPath}"
1218
+ name="${fieldPath}"
513
1219
  .value="${value || ''}"
514
- @change="${(e) => this.handleInputChange(fieldName, e)}"
1220
+ @change="${(e) => this.handleInputChange(fieldPath, e)}"
515
1221
  >
516
1222
  <option value="">-- Select --</option>
517
- ${enumValues.map(opt => html`
518
- <option value="${opt}" ?selected="${value === opt}">
519
- ${opt}
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="${fieldName}"
533
- name="${fieldName}"
1236
+ id="${fieldPath}"
1237
+ name="${fieldPath}"
534
1238
  .checked="${!!value}"
535
- @change="${(e) => this.handleInputChange(fieldName, e)}"
1239
+ @change="${(e) => this.handleInputChange(fieldPath, e)}"
536
1240
  />
537
- <label for="${fieldName}">${fieldSchema.title || fieldName}</label>
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="${fieldName}"
549
- name="${fieldName}"
1252
+ id="${fieldPath}"
1253
+ name="${fieldPath}"
550
1254
  .value="${value || ''}"
551
- @input="${(e) => this.handleInputChange(fieldName, e)}"
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="${fieldName}"
561
- name="${fieldName}"
1264
+ id="${fieldPath}"
1265
+ name="${fieldPath}"
562
1266
  .value="${value || ''}"
563
- @input="${(e) => this.handleInputChange(fieldName, e)}"
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="${fieldName}"
572
- name="${fieldName}"
1275
+ id="${fieldPath}"
1276
+ name="${fieldPath}"
573
1277
  .value="${value || ''}"
574
- @input="${(e) => this.handleInputChange(fieldName, e)}"
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="${fieldName}"
584
- name="${fieldName}"
1287
+ id="${fieldPath}"
1288
+ name="${fieldPath}"
585
1289
  .value="${value || ''}"
586
- @input="${(e) => this.handleInputChange(fieldName, e)}"
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="${fieldName}"
597
- name="${fieldName}"
1300
+ id="${fieldPath}"
1301
+ name="${fieldPath}"
598
1302
  .value="${value || ''}"
599
1303
  step="${type === 'integer' ? '1' : 'any'}"
600
- @input="${(e) => this.handleInputChange(fieldName, e)}"
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="${fieldName}"
610
- name="${fieldName}"
1313
+ id="${fieldPath}"
1314
+ name="${fieldPath}"
611
1315
  .value="${value || ''}"
612
- @input="${(e) => this.handleInputChange(fieldName, e)}"
1316
+ @input="${(e) => this.handleInputChange(fieldPath, e)}"
613
1317
  />
614
1318
  `;
615
1319
  }
616
1320
 
617
1321
  render() {
618
- if (this.loading) {
1322
+ if (this.error) {
619
1323
  return html`
620
1324
  <div class="container">
621
- <div class="loading">Loading form...</div>
1325
+ <div class="error"><strong>Error:</strong> ${this.error}</div>
622
1326
  </div>
623
1327
  `;
624
1328
  }
625
1329
 
626
- if (this.error) {
1330
+ if (this.loading) {
627
1331
  return html`
628
1332
  <div class="container">
629
- <div class="error">
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
- return html`
645
- ${this.customStyles ? html`<style>${this.customStyles}</style>` : ''}
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
- <div class="container">
648
- <form @submit="${this.handleSubmit}">
649
- ${this.schema.title ? html`<h2>${this.schema.title}</h2>` : ''}
650
- ${this.schema.description ? html`<p>${this.schema.description}</p>` : ''}
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
- ${Object.entries(this.schema.properties).map(([fieldName, fieldSchema]) =>
653
- this.renderField(fieldName, fieldSchema)
654
- )}
1370
+ // Regular form view (rest of existing render code)
1371
+ const hasFields = Object.keys(this.schema.properties).length > 0;
655
1372
 
656
- <div class="buttons">
657
- <button type="submit" ?disabled="${this.submitting}">
658
- <span class="button-content">
659
- ${this.submitting ? html`<span class="spinner"></span>` : ''}
660
- ${this.submitting ? 'Submitting...' : 'Submit'}
661
- </span>
662
- </button>
663
- <button type="button" @click="${this.handleReset}" ?disabled="${this.submitting}">
664
- Reset
665
- </button>
666
- </div>
667
- </form>
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
  }