chore(eslint): ESLint setup (#6708)
[openemr.git] / interface / modules / custom_modules / oe-module-comlink-telehealth / public / assets / js / src / add-patient-dialog.js
blob00268ca869ae6358a134ec3b4f48a6c9d14a64bf
1 /**
2  * Javascript Controller for the Add Patient Dialog window in the telehealth conference room.
3  *
4  * @package openemr
5  * @link      http://www.open-emr.org
6  * @author    Stephen Nielson <snielson@discoverandchange.com>
7  * @copyright Copyright (c) 2023 Comlink Inc <https://comlinkinc.com/>
8  * @license   https://github.com/openemr/openemr/blob/master/LICENSE GNU General Public License 3
9  */
11 export class AddPatientDialog
13     /**
14      *
15      * @type {bootstrap.Modal}
16      */
17     modal = null;
19     /**
20      *
21      * @type HTMLElement
22      */
23     container = null;
25     /**
26      *
27      * @type {number}
28      */
29     pc_eid = null;
31     /**
32      *
33      * @type {string}
34      */
35     scriptLocation = null;
37     /**
38      * Address of the FHIR api endpoint
39      * @type string
40      */
41     fhirLocation = null;
43     /**
44      *
45      * @type {function}
46      */
47     closeCallback = null;
49     /**
50      * Key value dictionary of the language translations
51      * @type {array}
52      * @private
53      */
54     __translations = null;
56     /**
57      * Used for making unique modal ids
58      * @type {number}
59      * @private
60      */
61     __idx = 0;
63     /**
64      * Cross Site Request Forgery token used to communicate with the internal API
65      * @type {null}
66      * @private
67      */
68     __apiCSRFToken = null;
70     /**
71      * The current screen we are displaying inside the dialog
72      * @type {string}
73      * @private
74      */
75     __currentScreen = null;
77     /**
78      * The caller settings received from the server that should be used for the telehealth session call
79      * @type {object}
80      * @private
81      */
82     __updatedCallerSettings = null;
84     /**
85      * The list of participants that are in the call
86      * @type {object}
87      * @private
88      */
89     __participantList = null;
91     /**
92      * Boolean that the DOM interface needs to be updated with the participant list changes.
93      * @type {boolean}
94      * @private
95      */
96     __updateParticipants = false;
98     constructor(apiCSRFToken, translations, pc_eid, scriptLocation, fhirLocation, participantList, closeCallback) {
99         this.pc_eid = pc_eid;
100         this.scriptLocation = scriptLocation;
101         this.fhirLocation = fhirLocation;
102         this.closeCallback = closeCallback;
103         this.__translations = translations;
104         this.__apiCSRFToken = apiCSRFToken;
106         this.__participantList = (participantList || []).filter(pl => pl.role !== 'provider');
107         this.__updateParticipants = false;
108     }
110     cancelDialog() {
111         // just have us call the close dialog piece.
112         this.closeDialogAndSendCallerSettings();
114     }
116     sendSaveParticipant(saveData) {
117         let postData = Object.assign({eid: this.pc_eid}, saveData);
118         let scriptLocation = this.scriptLocation + "?action=save_session_participant";
120         window.top.restoreSession();
121         return window.fetch(scriptLocation,
122             {
123                 method: 'POST'
124                 ,headers: {
125                     'Content-Type': 'application/json'
126                 }
127                 ,body: JSON.stringify(postData)
128                 ,redirect: 'manual'
129             })
130             .then(result => {
131                 if (result.status == 400 || (result.ok && result.status == 200)) {
132                     return result.json();
133                 } else {
134                     throw new Error("Failed to save participant in " + this.pc_eid + " with save data");
135                 }
136             })
137             .then(jsonResult => {
138                 if (jsonResult.error) {
139                     this.handleSaveParticipantErrorResponse(jsonResult);
140                     return;
141                 }
143                 let callerSettings = jsonResult.callerSettings || {};
144                 this.showActionAlert('success', this.__translations.PATIENT_INVITATION_SUCCESS);
145                 // let's show we were successful and close things up.
146                 this.__updatedCallerSettings = callerSettings;
147                 let participants = (callerSettings.participantList || []).filter(pl => pl.role != 'provider');
148                 this.__participantList = participants;
149                 this.__updateParticipants = true;
150                 this.updateParticipantControls();
151                 this.showPrimaryScreen();
152                 setTimeout(() => {
153                     this.closeDialogAndSendCallerSettings();
154                 }, 1000);
155             })
156     }
158     closeDialogAndSendCallerSettings() {
159         jQuery(this.container).on("hidden.bs.modal", () => {
160             try {
161                 jQuery(this.container).off("hidden.bs.modal");
162                 this.closeCallback(this.__updatedCallerSettings);
163             }
164             catch (error)
165             {
166                 console.error(error);
167             }
168             try {
169                 // make sure we destruct even if the callback fails
170                 this.destruct();
171             }
172             catch (error) {
173                 this.destruct();
174             }
175         });
176         this.modal.hide();
177     }
179     sendSearchResults(inputValues) {
180         // example FHIR request
181         // let url = '/apis/default/fhir/Patient/_search?given:contains=<fname>&family:contains=<lname>&birthDate=<dob>'
182         let url = this.fhirLocation + '/Patient';
183         let searchParams = [];
185         if (inputValues.pid) {
186             searchParams.push("identifier=" + encodeURIComponent(inputValues.pid));
187         }
188         if (inputValues.fname || inputValues.lname) {
189             let values = [];
190             if (inputValues.fname) {
191                 values.push(encodeURIComponent(inputValues.fname));
192             }
193             if (inputValues.lname) {
194                 values.push(encodeURIComponent(inputValues.lname));
195             }
196             searchParams.push("name:contains=" + values.join(","));
197         }
199         if (inputValues.DOB) {
200             // birthdate needs to be in Y-m-d prefix
201             searchParams.push("birthdate=" + encodeURIComponent(inputValues.DOB));
202         }
204         if (inputValues.email) {
205             searchParams.push("email:contains=" + encodeURIComponent(inputValues.email));
206         }
208         if (!searchParams.length) {
209             alert(this.__translations.SEARCH_REQUIRES_INPUT);
210             throw new Error("Failed to perform search due to missing search operator");
211         }
213         url += "?" + searchParams.join("&");
214         window.top.restoreSession();
215         let headers = {
216             'apicsrftoken': this.__apiCSRFToken
217         };
218         return window.fetch(url,
219             {
220                 method: 'GET'
221                 ,redirect: 'manual'
222                 ,headers: headers
223             })
224             .then(result => {
225                 if (!(result.ok && result.status == 200))
226                 {
227                     this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
228                     throw new Error("Failed to save participant in " + this.pc_eid + " with save data");
229                 } else {
230                     return result.json();
231                 }
232             })
233             .then(result => {
234                 if (result && Object.prototype.hasOwnProperty.call(result, 'total') && result.total <= 0) {
235                     this.showActionAlert('info', this.__translations.SEARCH_RESULTS_NOT_FOUND);
236                     return [];
237                 } else {
238                     this.clearActionAlerts();
239                     // return the array of result entries.
240                     return result.entry;
241                 }
242             })
243     }
245     showPrimaryScreen() {
246         let screens = this.container.querySelectorAll('.screen') || [];
247         screens.forEach(i => {
248             if (i.classList.contains('primary-screen')) {
249                 i.classList.remove('d-none');
250             } else {
251                 i.classList.add('d-none');
252             }
253         });
254         this.__currentScreen = 'primary-screen';
255         this.updateParticipantControls();
256     }
258     updateParticipantControls() {
260         // let's update if our pid is different
261         if (this.__participantList.length <= 0) {
262             this.container.querySelector('.no-third-party-patient-row').classList.remove('d-none');
263             this.container.querySelectorAll('.third-party-patient-row').forEach(n => n.classList.add('d-none'));
264             return;
265         }
266         this.container.querySelector('.no-third-party-patient-row').classList.add('d-none');
268         this.container.querySelectorAll('.third-party-patient-row').forEach(n => n.classList.remove('d-none'));
270         if (this.__updateParticipants) {
271             let participantContainers = this.container.querySelectorAll('.patient-participant');
272             if (!participantContainers.length) {
273                 console.error("Failed to find dom node with selector .patient-participant");
274                 return;
275             }
276             let templateNode = null;
277             participantContainers.forEach(pc => {
278                 if (!pc.classList.contains('template')) {
279                     pc.remove();
280                 } else {
281                     templateNode = pc;
282                 }
283             });
284             if (!templateNode) {
285                 console.error("Failed to find dom node with selector .patient-participant.template");
286                 return;
287             }
288             // now we clone for each participant we have
289             if (this.__participantList.length) {
290                 this.__participantList.forEach(p => {
291                     let clonedNode = templateNode.cloneNode(true);
292                     // note setting the innerText on these nodes already handles the escaping
293                     this.setNodeInnerText(clonedNode, '.patient-name', p.callerName);
294                     this.setNodeInnerText(clonedNode, '.patient-email', p.email);
295                     clonedNode.classList.remove('template');
296                     clonedNode.classList.remove('d-none');
298                     let invitation = p.invitation || {};
299                     let btnInvitationCopy = clonedNode.querySelector('.btn-invitation-copy');
300                     let btnLinkCopy = clonedNode.querySelector('.btn-link-copy');
301                     let btnGenerateLink = clonedNode.querySelector('.btn-generate-link');
303                     if (invitation) {
304                         if (btnGenerateLink) {
305                             // setup our link generation process since the user has enabled the onetime
306                             // note the server will check the onetime setting and reject requests if its not enabled
307                             btnGenerateLink.addEventListener('click', this.generatePatientLink.bind(this, p.id));
308                         }
309                         btnInvitationCopy.addEventListener('click', this.copyPatientInvitationToClipboard.bind(this));
310                         btnLinkCopy.addEventListener('click', this.copyPatientLinkToClipboard.bind(this));
311                         btnLinkCopy.dataset['inviteId'] = p.id;
313                         if (invitation.generated) {
314                             // show our buttons, we lave the generate link button open again in case they need to generate a new link.
315                             btnLinkCopy.classList.remove('d-none');
316                             btnInvitationCopy.classList.remove('d-none');
317                         }
319                         // invitation text is escaped from innerText
320                         this.setNodeInnerText(clonedNode, '.patient-invitation-text', invitation.text || "")
321                     } else {
322                         console.error("Failed to find invitation data for patient ", p);
323                         btnInvitationCopy.classList.add('d-none');
324                         btnLinkCopy.classList.add('d-none');
325                     }
327                     templateNode.parentNode.appendChild(clonedNode);
328                 });
329             }
330             this.__updateParticipants = false;
331         }
332     }
334     showNewPatientScreen() {
335         this.showSecondaryScreen('create-patient');
336     }
338     showSearchPatientScreen() {
339         this.showSecondaryScreen('search-patient');
340     }
342     showSecondaryScreen(screenName) {
343         let selector = '.' + screenName;
344         let primaryScreen = this.container.querySelector('.primary-screen');
345         if (!primaryScreen) {
346             console.error("Failed to find primary-screen selector for add-patient-dialog container");
347             return;
348         }
350         primaryScreen.classList.add('d-none');
352         let screen = this.container.querySelector(selector);
353         if (screen) {
354             screen.classList.remove('d-none');
355         } else {
356             console.error("Failed to find selector for add-patient-dialog container " + selector);
357         }
358         this.__currentScreen = screenName;
359     }
362     getInputValues(screen, inputs) {
363         let inputValues = {};
364         inputs.forEach((i) => {
365             let node = this.container.querySelector("." + screen + ' input[name="' + i + '"]');
366             if (node) {
367                 inputValues[i] = node.value;
368             } else {
369                 console.error("Failed to find input node with name " + i);
370                 inputValues[i] = null;
371             }
372         });
373         return inputValues;
374     }
376     inviteSearchResultPatient(pid) {
377         // hide the search results
378         this.displaySearchList(false);
379         this.showActionAlert('info', this.__translations.PATIENT_INVITATION_PROCESSING);
380         this.sendSaveParticipant({pid: pid})
381         .catch(error => {
382             console.error(error);
383             this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
384             // show the search results
385             this.displaySearchList(true);
386         })
387     }
389     displaySearchList(shouldDisplay) {
390         let selector = ".search-patient .search-patient-list";
391         let patientList = this.container.querySelector(selector);
392         if (!patientList) {
393             console.error("Failed to find ",selector);
394             return;
395         }
396         if (shouldDisplay) {
397             patientList.classList.remove('d-none');
398         } else {
399             patientList.classList.add('d-none');
400         }
401     }
403     populateSearchResults(result) {
404         console.log("populatingSearchResults with result ", result);
405         let selector = ".search-patient .search-patient-list";
406         let patientList = this.container.querySelector(selector);
407         if (!patientList) {
408             console.error("Failed to find ",selector);
409             return;
410         }
412         patientList.classList.remove('d-none');
413         let row = patientList.querySelector('.duplicate-match-row-template');
415         // clear out the table rows
416         let parentNode = row.parentNode;
417         parentNode.replaceChildren();
418         parentNode.appendChild(row);
420         // need to loop on the result and populate per row
421         result.forEach(r => {
422             let resource = r.resource;
423             let clonedNode = row.cloneNode(true);
424             clonedNode.classList.remove('duplicate-match-row-template');
425             parentNode.appendChild(clonedNode);
427             let pid = (resource.identifier || []).find(i => i.type.coding.find(cd => cd.code == "PT") !== undefined);
428             let pidValue = pid.value || "";
429             this.setNodeInnerText(clonedNode, '.pid', pidValue);
431             let birthDate = resource.birthDate;
432             if (birthDate) {
433                 this.setNodeInnerText(clonedNode, '.dob', birthDate);
434             }
436             let name = (resource.name || []).find(n => n.use == 'official');
437             if (name) {
438                 this.setNodeInnerText(clonedNode, '.fname', name.given.join(" "));
439                 this.setNodeInnerText(clonedNode, '.lname', name.family);
440             }
442             let email = (resource.telecom || []).find(t => t.system == 'email');
443             if (email) {
444                 this.setNodeInnerText(clonedNode, '.email', email.value);
445             } else {
446                 clonedNode.classList.add('missing-email');
447             }
449             if (pidValue) {
450                 clonedNode.querySelector('.btn-select-patient').addEventListener('click', () => {
451                     this.inviteSearchResultPatient(pidValue);
452                 });
453             }
454             clonedNode.classList.remove('d-none');
455         });
456     }
458     setNodeInnerText(node, selector, value) {
459         let subNode = node.querySelector(selector);
460         if (!subNode) {
461             console.error("Failed to find node with selector " + selector);
462             return;
463         }
464         subNode.textContent = value;
465     }
467     searchParticipantsAction()
468     {
469         let inputs = ['fname', 'lname', 'DOB', 'email', 'pid'];
470         let inputValues = this.getInputValues('search-patient', inputs);
471         // form validation happens server side.
472         this.toggleActionButton(false);
473         this.sendSearchResults(inputValues)
474             .then(result => {
475                 this.toggleActionButton(true);
476                 if (result.length) {
477                     this.populateSearchResults(result);
478                 }
479                 else {
480                     this.populateSearchResults([]);
481                     let resultMessage = this.__translations.SEARCH_RESULTS_NOT_FOUND;
482                 }
483             })
484             .catch(error => {
485                 this.toggleActionButton(true);
486                 console.error(error);
487             });
488     }
490     createPatientAction() {
491         let inputs = ['fname', 'lname', 'DOB', 'email'];
492         let inputValues = this.getInputValues('create-patient', inputs);
493         // for now we don't do the searching but we will do the invitation here...
494         this.clearActionAlerts();
495         this.showActionAlert('info', this.__translations.PATIENT_INVITATION_PROCESSING);
496         this.toggleActionButton(false);
497         this.sendSaveParticipant(inputValues)
498         .then(() => {
499             this.toggleActionButton(true);
500         })
501         .catch(error => {
502             this.toggleActionButton(true);
503             console.error(error);
504             this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
505         });
506     }
508     toggleActionButton(enabled) {
509         let btns = this.container.querySelectorAll('.btn-create-patient,.btn-invite-search');
510         btns.forEach(b => b.disabled = !enabled);
511     }
513     handleSaveParticipantErrorResponse(json) {
514         // need to display the error message.
515         let message = [];
516         if (json.fields) {
517             if (json.fields.DOB) {
518                 message.push(this.__translations.PATIENT_CREATE_INVALID_DOB);
519             }
520             if (json.fields.email) {
521                 message.push(this.__translations.PATIENT_CREATE_INVALID_EMAIL);
522             }
523             if (json.fields.fname || json.fields.lname) {
524                 message.push(this.__translations.PATIENT_CREATE_INVALID_NAME);
525             }
526             this.showActionAlert('danger', message.join(". "));
527         } else {
528             this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
529         }
530     }
532     clearActionAlerts() {
533         let alerts = this.container.querySelectorAll('.alert');
534         alerts.forEach(a => {
535             if (a.classList.contains('alert-template')) {
536                 a.classList.add('d-none');
537             } else {
538                 a.parentNode.removeChild(a);
539             }
540         });
541     }
543     showActionAlert(type, message) {
544         this.clearActionAlerts(); // for now we will just remove these, we could animate & stack if we wanted.
545         let template = this.container.querySelector('.alert.alert-template');
546         let alert = template.cloneNode(true);
547         alert.innerText = message;
548         alert.classList.remove('alert-template');
549         alert.classList.remove('d-none');
550         alert.classList.add('alert-' + type);
551         template.parentNode.insertBefore(alert, template);
552         alert.scrollIntoView();
553     }
555     setupModal() {
556         // see templates/comlink/conference-room.twig
557         let id = 'telehealth-container-invite-patient';
558         // let bootstrapModalTemplate = window.document.createElement('div');
559         // we use min-height 90vh until we get the bootstrap full screen modal in bootstrap 5
560         let node = document.getElementById(id);
561         // we are going to clone the node and add an index to this...
562         let clonedNode = node.cloneNode(true);
563         clonedNode.id = id + "-" + this.__idx++;
564         node.parentNode.appendChild(clonedNode);
565         this.container = clonedNode;
567         this.modal = new bootstrap.Modal(this.container, {keyboard: false, focus: true, backdrop: 'static'});
569         this.addActionToButton('.btn-telehealth-confirm-cancel', this.cancelDialog.bind(this));
570         this.addActionToButton('.btn-show-new-patient-screen', this.showNewPatientScreen.bind(this));
571         this.addActionToButton('.btn-show-search-patient-screen', this.showSearchPatientScreen.bind(this));
572         this.addActionToButton('.btn-cancel-screen-action', this.showPrimaryScreen.bind(this));
573         this.addActionToButton('.btn-create-patient', this.createPatientAction.bind(this));
574         this.addActionToButton('.btn-invite-search', this.searchParticipantsAction.bind(this));
576         // we update the participant list as it may have changed from when the DOM originally sent it down.
577         this.__updateParticipants = true;
578         this.updateParticipantControls();
579     }
581     copyPatientLinkToClipboard(evt) {
582         let target = evt.currentTarget;
583         if (!target) {
584             console.error("Failed to get a dom node cannot proceed with copy");
585             return;
586         }
588         let id = target.dataset['inviteId'];
589         if (!id) {
590             // no link just ignoring
591             console.error("Failed to find patient id to copy link for patient");
592             this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
593             return;
594         }
595         let participant = this.__participantList.find(pl => pl.id == id);
596         if (participant) {
597             let invitation = participant.invitation || {};
598             this.copyTextToClipboard(invitation.link || "");
599         } else {
600             this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
601         }
602     }
604     sendGeneratePatientLink(patientId) {
605         let postData = {pc_eid: this.pc_eid, pid: patientId};
606         let scriptLocation = this.scriptLocation + "?action=generate_participant_link";
608         window.top.restoreSession();
609         return window.fetch(scriptLocation,
610             {
611                 method: 'POST'
612                 ,headers: {
613                     'Content-Type': 'application/json'
614                 }
615                 ,body: JSON.stringify(postData)
616                 ,redirect: 'manual'
617             })
618             .then(result => {
619                 if (result.status == 400 || result.status == 401 || (result.ok && result.status == 200)) {
620                     return result.json();
621                 } else {
622                     throw new Error("Failed to generate participant link for participant " + this.pid
623                         + " with save data for session " + this.pc_eid);
624                 }
625             });
626     }
628     generatePatientLink(patientId, evt) {
629         // let's do a post to generate the link to OpenEMR
630         let target = evt.currentTarget;
632         // once we have the link we will update the button to copy the link.
633         // we will also insert the invitation text into the patient invitation text div.
634         // then we will show the copy button and the copy invitation button.
635         this.clearActionAlerts();
636         this.showActionAlert('info', this.__translations.PATIENT_INVITATION_PROCESSING);
637         this.toggleActionButton(false);
638         this.sendGeneratePatientLink(patientId)
639             .then(result => {
640                 this.toggleActionButton(true);
641                 this.clearActionAlerts();
642                 if (result.success) {
643                     let invitation = result.invitation;
644                     let patient = this.__participantList.find(p => p.id == patientId);
645                     if (patient) {
646                         patient.invitation = invitation;
647                         this.__updateParticipants = true;
648                         this.updateParticipantControls();
649                         this.showActionAlert('success', this.__translations.PATIENT_INVITATION_GENERATED);
650                     } else {
651                         this.showActionAlert('danger', this.__translations.PATIENT_INVITATION_FAILURE);
652                     }
653                 } else {
654                     this.showActionAlert('danger', this.__translations.PATIENT_INVITATION_FAILURE);
655                 }
656             })
657             .catch(error => {
658                 console.error(error);
659                 this.showActionAlert('danger', this.__translations.PATIENT_INVITATION_FAILURE);
660             });
661     }
663     copyPatientInvitationToClipboard(evt) {
664         let target = evt.target;
665         if (!target) {
666             console.error("Failed to get a dom node cannot proceed with copy");
667             return;
668         }
670         let closest = target.closest(".patient-participant[data-pid]");
671         if (!closest) {
672             this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
673             throw new Error("Failed to find patient to copy invitation");
674         }
675         let invitation = closest.querySelector(".patient-invitation-text");
676         if (!invitation) {
677             this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
678             throw new Error("Failed to find invitation text with selector .patient-invitation-text");
679         }
680         let text = invitation.textContent;
681         this.copyTextToClipboard(text);
682     }
684     copyTextToClipboard(text) {
686         // this is getting deprecated
687         if (navigator.clipboard && navigator.clipboard.writeText) {
688             navigator.clipboard.writeText(text).then(() => {
689                 this.showActionAlert('success', this.__translations.CLIPBOARD_COPY_SUCCESS);
690             })
691                 .catch(error => {
692                     console.error(error);
693                     this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
694                 })
695         } else {
696             console.error("clipboard.writeText does not exist");
697             this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
698         }
699     }
701     addActionToButton(selector, action) {
702         let btns = this.container.querySelectorAll(selector);
703         for (var i = 0; i < btns.length; i++)
704         {
705             btns[i].addEventListener('click', action);
706         }
707     }
709     show() {
710         if (!this.modal) {
711             this.setupModal();
712         }
714         this.modal.show();
715     }
717     destruct() {
718         this.modal = null;
719         // we clean everything up by removing the node which also removes the event listeners.
720         this.container.parentNode.removeChild(this.container);
721         this.container = null;
722     }