@opencitylabs/formio-sdk 1.2.0 → 1.3.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.
Files changed (2) hide show
  1. package/index.js +325 -3
  2. package/package.json +3 -3
package/index.js CHANGED
@@ -79,7 +79,8 @@ class FormIoHelper {
79
79
  const response = await axios.get(this.basePath + '/api/' + endPoint, {
80
80
  headers: {
81
81
  "Content-Type": "application/json",
82
- "Authorization": "Bearer " + token
82
+ "Authorization": "Bearer " + token,
83
+ "x-locale": this.getCurrentLocale()
83
84
  }
84
85
  })
85
86
  return response.data
@@ -92,7 +93,8 @@ class FormIoHelper {
92
93
  const response = await axios.post(this.basePath + '/api/' + endPoint, JSON.stringify(params),{
93
94
  headers: {
94
95
  "Content-Type": "application/json",
95
- "Authorization": "Bearer " + token
96
+ "Authorization": "Bearer " + token,
97
+ "x-locale": this.getCurrentLocale()
96
98
  }
97
99
  })
98
100
  return response.data
@@ -115,6 +117,7 @@ class FormIoHelper {
115
117
  headers: {
116
118
  "Content-Type": "application/json",
117
119
  Authorization: `Bearer ${token}`,
120
+ "x-locale": this.getCurrentLocale()
118
121
  },
119
122
  });
120
123
 
@@ -161,7 +164,8 @@ class FormIoHelper {
161
164
  async anonymousCall(endPoint) {
162
165
  const response = await axios.get(this.basePath + '/api/' + endPoint, {
163
166
  headers: {
164
- "Content-Type": "application/json"
167
+ "Content-Type": "application/json",
168
+ "x-locale": this.getCurrentLocale()
165
169
  }
166
170
  })
167
171
  return response.data
@@ -568,6 +572,324 @@ class FormIoHelper {
568
572
  }
569
573
  }
570
574
 
575
+ // --- PROFILE BLOCKS (PDND + Once-Only) ---
576
+
577
+ /**
578
+ * Applies PDND prefilling and once-only fallback to profile block components
579
+ * that have `sdk_mode: true` in their custom properties.
580
+ *
581
+ * @param {object} formInstance - Form.io instance (Webform/Wizard)
582
+ * @param {string} fiscalCode - User's codice fiscale
583
+ * @param {object} options
584
+ * @param {string} options.serviceId - Portal service ID (used to fetch PDND configs)
585
+ * @param {string} [options.userId] - Portal user ID (used for once-only API; omit for anonymous)
586
+ * @returns {Promise<Array<{data: object}>>} Data objects to merge via formInstance.setValue()
587
+ */
588
+ async applyProfileBlocks(formInstance, fiscalCode, options = {}) {
589
+ const { serviceId, userId } = options;
590
+
591
+ if (!formInstance || !fiscalCode) return [];
592
+
593
+ // Load PDND configs autonomously
594
+ let pdndConfigs = {};
595
+ if (serviceId) {
596
+ try {
597
+ pdndConfigs = await this.authenticatedCall(`services/${serviceId}/pdnd-configs`) || {};
598
+ } catch (e) {
599
+ console.warn('[applyProfileBlocks] Could not load PDND configs:', e);
600
+ }
601
+ }
602
+
603
+ const profileBlockComponents = this._findProfileBlockComponents(formInstance);
604
+ if (!profileBlockComponents.length) return [];
605
+
606
+ // Register once-only event listener once per formInstance lifetime
607
+ if (userId && !formInstance._sdkOnceOnlyListenerAttached) {
608
+ formInstance._sdkOnceOnlyListenerAttached = true;
609
+ formInstance.on('sdkOnceOnlySelected', ({ key, value }) => {
610
+ formInstance.emit('sdkProfileBlockData', { data: { [key]: value } });
611
+ });
612
+ }
613
+
614
+ const results = [];
615
+
616
+ for (const component of profileBlockComponents) {
617
+ await component.subFormReady.then(async (subForm) => {
618
+ if (!subForm?.components?.length) return;
619
+
620
+ // Already has PDND meta → just disable fields
621
+ if (component.getValue().meta !== undefined) {
622
+ await this._disableProfileBlockFields(component);
623
+ return;
624
+ }
625
+
626
+ const pdndComponent = subForm.components[0];
627
+ const eserviceId = pdndComponent?.component?.properties?.eservice;
628
+ const format = pdndComponent?.component?.properties?.format || 'default';
629
+
630
+ if (!eserviceId) {
631
+ if (userId) await this._initOnceOnly(formInstance, component, pdndComponent, userId);
632
+ return;
633
+ }
634
+
635
+ if (pdndConfigs[eserviceId]?.is_active) {
636
+ try {
637
+ const callUrl =
638
+ pdndConfigs[eserviceId].call_url +
639
+ `&fiscal_code=${encodeURIComponent(fiscalCode)}&format=${encodeURIComponent(format)}`;
640
+ const response = await this._getWithToken(callUrl);
641
+
642
+ results.push({ data: { [component.key]: { ...response } } });
643
+ await this._disableProfileBlockFields(component);
644
+ } catch (e) {
645
+ console.warn('[applyProfileBlocks] PDND call failed, falling back to once-only:', e);
646
+ if (userId) await this._initOnceOnly(formInstance, component, pdndComponent, userId);
647
+ }
648
+ } else {
649
+ if (userId) await this._initOnceOnly(formInstance, component, pdndComponent, userId);
650
+ }
651
+ }).catch(e => {
652
+ console.warn('[applyProfileBlocks] subFormReady error:', e);
653
+ });
654
+ }
655
+
656
+ return results;
657
+ }
658
+
659
+ _findProfileBlockComponents(formInstance) {
660
+ const components = [];
661
+ if (typeof formInstance.everyComponent === 'function') {
662
+ formInstance.everyComponent(component => {
663
+ if (
664
+ component.component.properties?.profile_block !== undefined &&
665
+ component.component.properties?.sdk_mode
666
+ ) {
667
+ components.push(component);
668
+ }
669
+ });
670
+ }
671
+ return components;
672
+ }
673
+
674
+ async _getWithToken(url) {
675
+ const token = await this.getCurrentToken();
676
+ const response = await axios.get(url, {
677
+ headers: {
678
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
679
+ 'x-locale': this.getCurrentLocale(),
680
+ },
681
+ });
682
+ return response.data;
683
+ }
684
+
685
+ async _disableProfileBlockFields(component) {
686
+ const componentData = component.getValue();
687
+ const source = componentData?.meta?.source || '';
688
+ const createdAt = componentData?.meta?.created_at;
689
+ const formattedDate = createdAt
690
+ ? new Date(createdAt).toLocaleDateString(this.getCurrentLocale() || 'it-IT')
691
+ : null;
692
+
693
+ // Inject PDND alert into component description (rendered natively by Form.io)
694
+ const alertHtml =
695
+ `<p class="mt-n4 pdnd-alert">I dati contrassegnati con ` +
696
+ `<span class="text-success"><i class="bi bi-check-circle"></i></span> ` +
697
+ `provengono da ${source}` +
698
+ (formattedDate ? ` aggiornati al ${formattedDate}` : '') +
699
+ `</p>`;
700
+
701
+ if (!component.component.description?.includes('pdnd-alert')) {
702
+ component.component.description = (component.component.description || '') + alertHtml;
703
+ if (typeof component.redraw === 'function') component.redraw();
704
+ }
705
+
706
+ if (!component.subFormReady) return;
707
+
708
+ component.subFormReady.then(subForm => {
709
+ if (!subForm?.components) return;
710
+
711
+ this._traverseSubformComponents(subForm.components, subComponent => {
712
+ if (subComponent.type === 'datagrid') {
713
+ subComponent.component.disableAddingRemovingRows = true;
714
+ if (typeof subComponent.redraw === 'function') subComponent.redraw();
715
+ }
716
+
717
+ if (subComponent.component.properties?.pdnd_field) {
718
+ // Add success icon via description (prefix/suffix would appear near input, not label)
719
+ if (!subComponent.component.description?.includes('pdnd-success-icon')) {
720
+ subComponent.component.description =
721
+ `<small class="me-1 text-success pdnd-success-icon"><i class="bi bi-check-circle"></i></small>` +
722
+ (subComponent.component.description || '');
723
+ }
724
+ subComponent.component.disabled = true;
725
+ if (typeof subComponent.redraw === 'function') subComponent.redraw();
726
+ }
727
+ });
728
+ });
729
+ }
730
+
731
+ async _initOnceOnly(formInstance, component, nestedFormComponent, userId) {
732
+ if (!component.rendered) return;
733
+ if (
734
+ component.path === 'applicant.data.address' ||
735
+ component.component.key === 'applicant'
736
+ ) return;
737
+
738
+ const componentKey = component.component.key;
739
+
740
+ // Avoid double initialization
741
+ if (component.component.description?.includes('data-once-only-key')) return;
742
+
743
+ const isDatagrid =
744
+ nestedFormComponent?.type === 'datagrid' ||
745
+ nestedFormComponent?.components?.[0]?.type === 'datagrid';
746
+
747
+ try {
748
+ const response = await this.authenticatedCall(
749
+ `users/${userId}/profile-blocks?keys=${componentKey}`
750
+ );
751
+ if (!response?.data?.length) return;
752
+
753
+ if (isDatagrid) {
754
+ const datagridComponent =
755
+ nestedFormComponent.type === 'datagrid'
756
+ ? nestedFormComponent
757
+ : nestedFormComponent.components[0];
758
+
759
+ const datagridKey = datagridComponent.component.key;
760
+ const datagridData = component.getValue().data?.[datagridKey];
761
+ const isEmpty =
762
+ !datagridData ||
763
+ (Array.isArray(datagridData) &&
764
+ (datagridData.length === 0 ||
765
+ (datagridData.length === 1 &&
766
+ !Object.values(datagridData[0]).some(Boolean))));
767
+
768
+ if (isEmpty) {
769
+ const value = { ...response.data[0].value };
770
+ if ('meta' in value) delete value.meta;
771
+ formInstance.emit('sdkProfileBlockData', {
772
+ data: { [datagridKey]: value },
773
+ });
774
+ }
775
+ } else {
776
+ const entries = {};
777
+ response.data.forEach(d => { entries[d.id] = d; });
778
+
779
+ // Inject CTA into description — Form.io renders this as HTML natively
780
+ const ctaHtml =
781
+ `<p class="once-only-cta mt-2">` +
782
+ `Compila i campi seguenti oppure ` +
783
+ `<a href="#" data-once-only-key="${componentKey}">` +
784
+ `utilizza le informazioni che hai già fornito ` +
785
+ `<i class="bi bi-box-arrow-up-right"></i></a></p>`;
786
+
787
+ component.component.description = (component.component.description || '') + ctaHtml;
788
+ component.redraw();
789
+
790
+ // Scoped click listener — NOT on document, only on this component's element
791
+ component.element?.addEventListener('click', e => {
792
+ const anchor = e.target.closest('[data-once-only-key]');
793
+ if (!anchor) return;
794
+ e.preventDefault();
795
+ this._showOnceOnlyModal(formInstance, component, entries, userId);
796
+ });
797
+ }
798
+ } catch (e) {
799
+ console.warn('[applyProfileBlocks] once-only error:', e);
800
+ }
801
+ }
802
+
803
+ _showOnceOnlyModal(formInstance, component, entries, userId) {
804
+ let html = '<div class="it-list-wrapper"><ul class="it-list">';
805
+ for (const id in entries) {
806
+ const d = entries[id];
807
+ const dateStr = new Date(d.updated_at).toLocaleDateString('it-IT');
808
+ const timeStr = new Date(d.updated_at).toLocaleTimeString('it-IT', {
809
+ hour: '2-digit',
810
+ minute: '2-digit',
811
+ });
812
+ let name = d.unique_id;
813
+ const subtitle = `Valore inserito in data ${dateStr} alle ${timeStr}`;
814
+ if (!name || name === 'undefined' || name === '') name = subtitle;
815
+
816
+ // Escape single quotes to avoid breaking data-content attribute
817
+ const safeContent = JSON.stringify(d.value).replace(/'/g, '&#39;');
818
+
819
+ html += `<li>
820
+ <div class="list-item">
821
+ <button class="btn btn-link btn-xs text-left select-once-only-entry" data-content='${safeContent}' type="button">
822
+ <p class="text text-start mb-0">${name}</p>
823
+ <p class="text-info">${subtitle}</p>
824
+ </button>
825
+ <div class="it-right-zone justify-content-end">
826
+ <button class="btn btn-outline-danger btn-xs mr-1 delete-once-only-entry" data-id="${d.id}" type="button">
827
+ <i class="bi bi-trash px-1 fs-5"></i>
828
+ </button>
829
+ </div>
830
+ </div>
831
+ </li>`;
832
+ }
833
+ html += '</ul></div>';
834
+
835
+ Swal.fire({
836
+ title: 'Seleziona tra i valori precedentemente salvati',
837
+ showCloseButton: true,
838
+ html,
839
+ showConfirmButton: false,
840
+ showCancelButton: false,
841
+ didOpen: () => {
842
+ Swal.getPopup().querySelectorAll('.select-once-only-entry').forEach(el => {
843
+ el.addEventListener('click', e => {
844
+ e.preventDefault();
845
+ const value = JSON.parse(el.dataset.content);
846
+ formInstance.emit('sdkOnceOnlySelected', { key: component.path, value });
847
+ Swal.close();
848
+ });
849
+ });
850
+
851
+ Swal.getPopup().querySelectorAll('.delete-once-only-entry').forEach(el => {
852
+ el.addEventListener('click', async () => {
853
+ const id = el.dataset.id;
854
+ const result = await Swal.fire({
855
+ title: 'Sei sicuro di voler procedere?',
856
+ showDenyButton: true,
857
+ showCancelButton: false,
858
+ confirmButtonText: 'Elimina',
859
+ denyButtonText: 'Annulla',
860
+ customClass: {
861
+ confirmButton: 'btn btn-sm btn-danger',
862
+ denyButton: 'btn btn-sm btn-outline',
863
+ },
864
+ });
865
+ if (result.isConfirmed) {
866
+ try {
867
+ await this.authenticatedRequest('DELETE', `users/${userId}/profile-blocks/${id}`);
868
+ delete entries[id];
869
+ el.closest('li').remove();
870
+ if (Swal.getPopup().querySelectorAll('.delete-once-only-entry').length === 0) {
871
+ Swal.close();
872
+ }
873
+ } catch (err) {
874
+ console.error('[applyProfileBlocks] delete profile block error:', err);
875
+ }
876
+ }
877
+ });
878
+ });
879
+ },
880
+ });
881
+ }
882
+
883
+ _traverseSubformComponents(components, callback) {
884
+ if (!components?.length) return;
885
+ components.forEach(component => {
886
+ callback(component);
887
+ if (component.components?.length) {
888
+ this._traverseSubformComponents(component.components, callback);
889
+ }
890
+ });
891
+ }
892
+
571
893
  }
572
894
 
573
895
  function createFormioHelper(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencitylabs/formio-sdk",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Node/browser SDK helper for Form.io APIs",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -24,9 +24,9 @@
24
24
  "author": "OpencityLabs",
25
25
  "license": "ISC",
26
26
  "dependencies": {
27
- "axios": "^1.16.0",
27
+ "axios": "^1.18.1",
28
28
  "jwt-decode": "^4.0.0",
29
29
  "lodash.get": "^4.4.2",
30
- "sweetalert2": "^11.26.24"
30
+ "sweetalert2": "^11.26.25"
31
31
  }
32
32
  }