@uportal/form-builder 1.3.2 → 2.0.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,673 @@
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
+ }
7
+
8
+ /**
9
+ * Dynamic Form Builder Web Component
10
+ * Fetches JSON schema and form data, then renders a dynamic form
11
+ *
12
+ * @element form-builder
13
+ *
14
+ * @attr {string} fbms-base-url - Base URL of the form builder microservice
15
+ * @attr {string} fbms-form-fname - Form name to fetch
16
+ * @attr {string} oidc-url - OpenID Connect URL for authentication
17
+ * @attr {string} styles - Optional custom CSS styles
18
+ */
19
+ class FormBuilder extends LitElement {
20
+ static properties = {
21
+ fbmsBaseUrl: { type: String, attribute: 'fbms-base-url' },
22
+ fbmsFormFname: { type: String, attribute: 'fbms-form-fname' },
23
+ oidcUrl: { type: String, attribute: 'oidc-url' },
24
+ customStyles: { type: String, attribute: 'styles' },
25
+
26
+ // Internal state
27
+ schema: { type: Object, state: true },
28
+ formData: { type: Object, state: true },
29
+ uiSchema: { type: Object, state: true },
30
+ fbmsFormVersion: { type: String, state: true },
31
+ loading: { type: Boolean, state: true },
32
+ submitting: { type: Boolean, state: true },
33
+ error: { type: String, state: true },
34
+ token: { type: String, state: true },
35
+ decoded: { type: Object, state: true },
36
+ };
37
+
38
+ static styles = css`
39
+ :host {
40
+ display: block;
41
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
42
+ }
43
+
44
+ .container {
45
+ max-width: 800px;
46
+ margin: 0 auto;
47
+ padding: 20px;
48
+ }
49
+
50
+ .loading {
51
+ text-align: center;
52
+ padding: 40px;
53
+ color: #666;
54
+ }
55
+
56
+ .error {
57
+ background-color: #fee;
58
+ border: 1px solid #fcc;
59
+ border-radius: 4px;
60
+ padding: 15px;
61
+ margin: 20px 0;
62
+ color: #c00;
63
+ }
64
+
65
+ form {
66
+ display: flex;
67
+ flex-direction: column;
68
+ gap: 20px;
69
+ }
70
+
71
+ .form-group {
72
+ display: flex;
73
+ flex-direction: column;
74
+ gap: 8px;
75
+ }
76
+
77
+ label {
78
+ font-weight: 500;
79
+ color: #333;
80
+ }
81
+
82
+ .required::after {
83
+ content: ' *';
84
+ color: #c00;
85
+ }
86
+
87
+ .description {
88
+ font-size: 0.875rem;
89
+ color: #666;
90
+ margin-top: 4px;
91
+ }
92
+
93
+ input[type="text"],
94
+ input[type="email"],
95
+ input[type="number"],
96
+ input[type="date"],
97
+ input[type="tel"],
98
+ textarea,
99
+ select {
100
+ padding: 8px 12px;
101
+ border: 1px solid #ccc;
102
+ border-radius: 4px;
103
+ font-size: 1rem;
104
+ font-family: inherit;
105
+ width: 100%;
106
+ box-sizing: border-box;
107
+ }
108
+
109
+ input:focus,
110
+ textarea:focus,
111
+ select:focus {
112
+ outline: none;
113
+ border-color: #0066cc;
114
+ box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
115
+ }
116
+
117
+ textarea {
118
+ min-height: 100px;
119
+ resize: vertical;
120
+ }
121
+
122
+ input[type="checkbox"],
123
+ input[type="radio"] {
124
+ margin-right: 8px;
125
+ }
126
+
127
+ .checkbox-group,
128
+ .radio-group {
129
+ display: flex;
130
+ flex-direction: column;
131
+ gap: 8px;
132
+ }
133
+
134
+ .checkbox-item,
135
+ .radio-item {
136
+ display: flex;
137
+ align-items: center;
138
+ }
139
+
140
+ .error-message {
141
+ color: #c00;
142
+ font-size: 0.875rem;
143
+ margin-top: 4px;
144
+ }
145
+
146
+ .buttons {
147
+ display: flex;
148
+ gap: 12px;
149
+ margin-top: 20px;
150
+ }
151
+
152
+ button {
153
+ padding: 10px 20px;
154
+ border: none;
155
+ border-radius: 4px;
156
+ font-size: 1rem;
157
+ cursor: pointer;
158
+ font-family: inherit;
159
+ transition: background-color 0.2s;
160
+ }
161
+
162
+ button[type="submit"] {
163
+ background-color: #0066cc;
164
+ color: white;
165
+ }
166
+
167
+ button[type="submit"]:hover {
168
+ background-color: #0052a3;
169
+ }
170
+
171
+ button[type="button"] {
172
+ background-color: #c0c0c0;
173
+ color: #333;
174
+ }
175
+
176
+ button[type="button"]:hover {
177
+ background-color: #e0e0e0;
178
+ }
179
+
180
+ button:disabled {
181
+ opacity: 0.5;
182
+ cursor: not-allowed;
183
+ }
184
+
185
+ .spinner {
186
+ display: inline-block;
187
+ width: 1em;
188
+ height: 1em;
189
+ border: 2px solid rgba(255, 255, 255, 0.3);
190
+ border-radius: 50%;
191
+ border-top-color: white;
192
+ animation: spin 0.8s linear infinite;
193
+ margin-right: 8px;
194
+ vertical-align: middle;
195
+ }
196
+
197
+ @keyframes spin {
198
+ to { transform: rotate(360deg); }
199
+ }
200
+
201
+ .button-content {
202
+ display: inline-flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ }
206
+ `;
207
+
208
+ constructor() {
209
+ super();
210
+ this.loading = true;
211
+ this.submitting = false;
212
+ this.error = null;
213
+ this.schema = null;
214
+ this.formData = {};
215
+ this.uiSchema = null;
216
+ this.fbmsFormVersion = null;
217
+ this.token = null;
218
+ this.decoded = {sub: 'unknown'};
219
+ this.fieldErrors = {};
220
+ }
221
+
222
+ async connectedCallback() {
223
+ super.connectedCallback();
224
+ await this.initialize();
225
+ }
226
+
227
+ async initialize() {
228
+ try {
229
+ this.loading = true;
230
+ this.error = null;
231
+
232
+ // Fetch OIDC token if URL provided
233
+ if (this.oidcUrl) {
234
+ await this.fetchToken();
235
+ }
236
+
237
+ // Fetch form schema and data
238
+ await Promise.all([
239
+ this.fetchSchema(),
240
+ this.fetchFormData(),
241
+ ]);
242
+
243
+ this.loading = false;
244
+ } catch (err) {
245
+ this.error = err.message || 'Failed to initialize form';
246
+ this.loading = false;
247
+ }
248
+ }
249
+
250
+ async fetchToken() {
251
+ try {
252
+ const response = await fetch(this.oidcUrl, {
253
+ credentials: 'include',
254
+ });
255
+
256
+ if (!response.ok) {
257
+ throw new Error('Failed to authenticate');
258
+ }
259
+
260
+ const data = await response.text();
261
+ this.token = data
262
+ this.decoded = decode(this.token);
263
+ } catch (err) {
264
+ console.error('Token fetch error:', err);
265
+ throw new Error('Authentication failed');
266
+ }
267
+ }
268
+
269
+ async fetchSchema() {
270
+ const url = `${this.fbmsBaseUrl}/api/v1/forms/${this.fbmsFormFname}`;
271
+ const headers = {
272
+ 'content-type': 'application/jwt',
273
+ };
274
+
275
+ if (this.token) {
276
+ headers['Authorization'] = `Bearer ${this.token}`;
277
+ }
278
+
279
+ const response = await fetch(url, {
280
+ credentials: 'same-origin',
281
+ headers,
282
+ });
283
+
284
+ if (!response.ok) {
285
+ throw new Error(`Failed to fetch schema: ${response.statusText}`);
286
+ }
287
+
288
+ const data = await response.json();
289
+ this.fbmsFormVersion = data.version;
290
+ this.schema = data.schema || data;
291
+ this.uiSchema = data.metadata;
292
+ }
293
+
294
+ async fetchFormData() {
295
+ const url = `${this.fbmsBaseUrl}/api/v1/submissions/${this.fbmsFormFname}?safarifix=${Math.random()}`;
296
+ const headers = {
297
+ 'content-type': 'application/jwt',
298
+ };
299
+
300
+ if (this.token) {
301
+ headers['Authorization'] = `Bearer ${this.token}`;
302
+ }
303
+
304
+ try {
305
+ const response = await fetch(url, {
306
+ credentials: 'same-origin',
307
+ headers,
308
+ });
309
+
310
+ if (response.ok) {
311
+ const payload = await response.json();
312
+ this.formData = payload.answers;
313
+ } else {
314
+ // It's OK if there's no existing data
315
+ this.formData = {};
316
+ }
317
+ } catch (err) {
318
+ // Non-critical error
319
+ console.warn('Could not fetch form data:', err);
320
+ this.formData = {};
321
+ }
322
+ }
323
+
324
+ handleInputChange(fieldName, event) {
325
+ const { type, value, checked } = event.target;
326
+
327
+ this.formData = {
328
+ ...this.formData,
329
+ [fieldName]: type === 'checkbox' ? checked : value,
330
+ };
331
+
332
+ // Clear field error on change
333
+ if (this.fieldErrors[fieldName]) {
334
+ this.fieldErrors = { ...this.fieldErrors };
335
+ delete this.fieldErrors[fieldName];
336
+ }
337
+ }
338
+
339
+ handleArrayChange(fieldName, index, event) {
340
+ const currentArray = this.formData[fieldName] || [];
341
+ const newArray = [...currentArray];
342
+ newArray[index] = event.target.value;
343
+
344
+ this.formData = {
345
+ ...this.formData,
346
+ [fieldName]: newArray,
347
+ };
348
+ }
349
+
350
+ validateForm() {
351
+ const errors = {};
352
+ const { properties = {}, required = [] } = this.schema;
353
+
354
+ // Check required fields
355
+ required.forEach(fieldName => {
356
+ const value = this.formData[fieldName];
357
+ if (value === undefined || value === null || value === '') {
358
+ errors[fieldName] = 'This field is required';
359
+ }
360
+ });
361
+
362
+ // Type validation
363
+ Object.entries(properties).forEach(([fieldName, fieldSchema]) => {
364
+ const value = this.formData[fieldName];
365
+
366
+ if (value !== undefined && value !== null && value !== '') {
367
+ // Email validation
368
+ if (fieldSchema.format === 'email') {
369
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
370
+ if (!emailRegex.test(value)) {
371
+ errors[fieldName] = 'Invalid email address';
372
+ }
373
+ }
374
+
375
+ // Number validation
376
+ if (fieldSchema.type === 'number' || fieldSchema.type === 'integer') {
377
+ const num = Number(value);
378
+ if (isNaN(num)) {
379
+ errors[fieldName] = 'Must be a number';
380
+ } else {
381
+ if (fieldSchema.minimum !== undefined && num < fieldSchema.minimum) {
382
+ errors[fieldName] = `Must be at least ${fieldSchema.minimum}`;
383
+ }
384
+ if (fieldSchema.maximum !== undefined && num > fieldSchema.maximum) {
385
+ errors[fieldName] = `Must be at most ${fieldSchema.maximum}`;
386
+ }
387
+ }
388
+ }
389
+
390
+ // String length validation
391
+ if (fieldSchema.type === 'string') {
392
+ if (fieldSchema.minLength && value.length < fieldSchema.minLength) {
393
+ errors[fieldName] = `Must be at least ${fieldSchema.minLength} characters`;
394
+ }
395
+ if (fieldSchema.maxLength && value.length > fieldSchema.maxLength) {
396
+ errors[fieldName] = `Must be at most ${fieldSchema.maxLength} characters`;
397
+ }
398
+ }
399
+ }
400
+ });
401
+
402
+ this.fieldErrors = errors;
403
+ return Object.keys(errors).length === 0;
404
+ }
405
+
406
+ async handleSubmit(event) {
407
+ event.preventDefault();
408
+
409
+ if (!this.validateForm()) {
410
+ this.requestUpdate();
411
+ return;
412
+ }
413
+
414
+ // Prevent double-submission
415
+ if (this.submitting) {
416
+ return;
417
+ }
418
+
419
+ try {
420
+ this.submitting = true;
421
+ this.error = null;
422
+ const body = {
423
+ username: this.decoded.sub,
424
+ formFname: this.fbmsFormFname,
425
+ formVersion: this.fbmsFormVersion,
426
+ timestamp: Date.now(),
427
+ answers: this.formData
428
+ };
429
+ await delay(300);
430
+
431
+ const url = `${this.fbmsBaseUrl}/api/v1/submissions/${this.fbmsFormFname}`;
432
+ const headers = {
433
+ 'content-type': 'application/json',
434
+ };
435
+
436
+ if (this.token) {
437
+ headers['Authorization'] = `Bearer ${this.token}`;
438
+ }
439
+
440
+ const response = await fetch(url, {
441
+ method: 'POST',
442
+ credentials: 'same-origin',
443
+ headers,
444
+ body: JSON.stringify(body),
445
+ });
446
+
447
+ if (!response.ok) {
448
+ throw new Error(`Failed to submit form: ${response.statusText}`);
449
+ }
450
+
451
+ // Dispatch success event
452
+ this.dispatchEvent(new CustomEvent('form-submit-success', {
453
+ detail: { data: body },
454
+ bubbles: true,
455
+ composed: true,
456
+ }));
457
+
458
+ // Optional: Reset or show success message
459
+ this.error = null;
460
+ } 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
+ }));
468
+ } finally {
469
+ this.submitting = false;
470
+ }
471
+ }
472
+
473
+ handleReset() {
474
+ this.formData = {};
475
+ this.fieldErrors = {};
476
+ this.requestUpdate();
477
+ }
478
+
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] || {};
484
+
485
+ return html`
486
+ <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
+ ` : ''}
494
+
495
+ ${this.renderInput(fieldName, fieldSchema, value, uiOptions)}
496
+
497
+ ${error ? html`
498
+ <span class="error-message">${error}</span>
499
+ ` : ''}
500
+ </div>
501
+ `;
502
+ }
503
+
504
+ renderInput(fieldName, fieldSchema, value, uiOptions) {
505
+ const { type, enum: enumValues, format } = fieldSchema;
506
+
507
+ // Enum - render as select
508
+ if (enumValues) {
509
+ return html`
510
+ <select
511
+ id="${fieldName}"
512
+ name="${fieldName}"
513
+ .value="${value || ''}"
514
+ @change="${(e) => this.handleInputChange(fieldName, e)}"
515
+ >
516
+ <option value="">-- Select --</option>
517
+ ${enumValues.map(opt => html`
518
+ <option value="${opt}" ?selected="${value === opt}">
519
+ ${opt}
520
+ </option>
521
+ `)}
522
+ </select>
523
+ `;
524
+ }
525
+
526
+ // Boolean - render as checkbox
527
+ if (type === 'boolean') {
528
+ return html`
529
+ <div class="checkbox-item">
530
+ <input
531
+ type="checkbox"
532
+ id="${fieldName}"
533
+ name="${fieldName}"
534
+ .checked="${!!value}"
535
+ @change="${(e) => this.handleInputChange(fieldName, e)}"
536
+ />
537
+ <label for="${fieldName}">${fieldSchema.title || fieldName}</label>
538
+ </div>
539
+ `;
540
+ }
541
+
542
+ // String with format
543
+ if (type === 'string') {
544
+ if (format === 'email') {
545
+ return html`
546
+ <input
547
+ type="email"
548
+ id="${fieldName}"
549
+ name="${fieldName}"
550
+ .value="${value || ''}"
551
+ @input="${(e) => this.handleInputChange(fieldName, e)}"
552
+ />
553
+ `;
554
+ }
555
+
556
+ if (format === 'date') {
557
+ return html`
558
+ <input
559
+ type="date"
560
+ id="${fieldName}"
561
+ name="${fieldName}"
562
+ .value="${value || ''}"
563
+ @input="${(e) => this.handleInputChange(fieldName, e)}"
564
+ />
565
+ `;
566
+ }
567
+
568
+ if (uiOptions['ui:widget'] === 'textarea') {
569
+ return html`
570
+ <textarea
571
+ id="${fieldName}"
572
+ name="${fieldName}"
573
+ .value="${value || ''}"
574
+ @input="${(e) => this.handleInputChange(fieldName, e)}"
575
+ ></textarea>
576
+ `;
577
+ }
578
+
579
+ // Default text input
580
+ return html`
581
+ <input
582
+ type="text"
583
+ id="${fieldName}"
584
+ name="${fieldName}"
585
+ .value="${value || ''}"
586
+ @input="${(e) => this.handleInputChange(fieldName, e)}"
587
+ />
588
+ `;
589
+ }
590
+
591
+ // Number
592
+ if (type === 'number' || type === 'integer') {
593
+ return html`
594
+ <input
595
+ type="number"
596
+ id="${fieldName}"
597
+ name="${fieldName}"
598
+ .value="${value || ''}"
599
+ step="${type === 'integer' ? '1' : 'any'}"
600
+ @input="${(e) => this.handleInputChange(fieldName, e)}"
601
+ />
602
+ `;
603
+ }
604
+
605
+ // Fallback
606
+ return html`
607
+ <input
608
+ type="text"
609
+ id="${fieldName}"
610
+ name="${fieldName}"
611
+ .value="${value || ''}"
612
+ @input="${(e) => this.handleInputChange(fieldName, e)}"
613
+ />
614
+ `;
615
+ }
616
+
617
+ render() {
618
+ if (this.loading) {
619
+ return html`
620
+ <div class="container">
621
+ <div class="loading">Loading form...</div>
622
+ </div>
623
+ `;
624
+ }
625
+
626
+ if (this.error) {
627
+ return html`
628
+ <div class="container">
629
+ <div class="error">
630
+ <strong>Error:</strong> ${this.error}
631
+ </div>
632
+ </div>
633
+ `;
634
+ }
635
+
636
+ if (!this.schema || !this.schema.properties) {
637
+ return html`
638
+ <div class="container">
639
+ <div class="error">Invalid form schema</div>
640
+ </div>
641
+ `;
642
+ }
643
+
644
+ return html`
645
+ ${this.customStyles ? html`<style>${this.customStyles}</style>` : ''}
646
+
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>` : ''}
651
+
652
+ ${Object.entries(this.schema.properties).map(([fieldName, fieldSchema]) =>
653
+ this.renderField(fieldName, fieldSchema)
654
+ )}
655
+
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>
668
+ </div>
669
+ `;
670
+ }
671
+ }
672
+
673
+ customElements.define('form-builder', FormBuilder);