2 * Javascript Controller for the Add Patient Dialog window in the telehealth conference room.
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
11 export class AddPatientDialog
15 * @type {bootstrap.Modal}
35 scriptLocation = null;
38 * Address of the FHIR api endpoint
50 * Key value dictionary of the language translations
54 __translations = null;
57 * Used for making unique modal ids
64 * Cross Site Request Forgery token used to communicate with the internal API
68 __apiCSRFToken = null;
71 * The current screen we are displaying inside the dialog
75 __currentScreen = null;
78 * The caller settings received from the server that should be used for the telehealth session call
82 __updatedCallerSettings = null;
85 * The list of participants that are in the call
89 __participantList = null;
92 * Boolean that the DOM interface needs to be updated with the participant list changes.
96 __updateParticipants = false;
98 constructor(apiCSRFToken, translations, pc_eid, scriptLocation, fhirLocation, participantList, closeCallback) {
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;
111 // just have us call the close dialog piece.
112 this.closeDialogAndSendCallerSettings();
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,
125 'Content-Type': 'application/json'
127 ,body: JSON.stringify(postData)
131 if (result.status == 400 || (result.ok && result.status == 200)) {
132 return result.json();
134 throw new Error("Failed to save participant in " + this.pc_eid + " with save data");
137 .then(jsonResult => {
138 if (jsonResult.error) {
139 this.handleSaveParticipantErrorResponse(jsonResult);
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();
153 this.closeDialogAndSendCallerSettings();
158 closeDialogAndSendCallerSettings() {
159 jQuery(this.container).on("hidden.bs.modal", () => {
161 jQuery(this.container).off("hidden.bs.modal");
162 this.closeCallback(this.__updatedCallerSettings);
166 console.error(error);
169 // make sure we destruct even if the callback fails
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));
188 if (inputValues.fname || inputValues.lname) {
190 if (inputValues.fname) {
191 values.push(encodeURIComponent(inputValues.fname));
193 if (inputValues.lname) {
194 values.push(encodeURIComponent(inputValues.lname));
196 searchParams.push("name:contains=" + values.join(","));
199 if (inputValues.DOB) {
200 // birthdate needs to be in Y-m-d prefix
201 searchParams.push("birthdate=" + encodeURIComponent(inputValues.DOB));
204 if (inputValues.email) {
205 searchParams.push("email:contains=" + encodeURIComponent(inputValues.email));
208 if (!searchParams.length) {
209 alert(this.__translations.SEARCH_REQUIRES_INPUT);
210 throw new Error("Failed to perform search due to missing search operator");
213 url += "?" + searchParams.join("&");
214 window.top.restoreSession();
216 'apicsrftoken': this.__apiCSRFToken
218 return window.fetch(url,
225 if (!(result.ok && result.status == 200))
227 this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
228 throw new Error("Failed to save participant in " + this.pc_eid + " with save data");
230 return result.json();
234 if (result && Object.prototype.hasOwnProperty.call(result, 'total') && result.total <= 0) {
235 this.showActionAlert('info', this.__translations.SEARCH_RESULTS_NOT_FOUND);
238 this.clearActionAlerts();
239 // return the array of result entries.
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');
251 i.classList.add('d-none');
254 this.__currentScreen = 'primary-screen';
255 this.updateParticipantControls();
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'));
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");
276 let templateNode = null;
277 participantContainers.forEach(pc => {
278 if (!pc.classList.contains('template')) {
285 console.error("Failed to find dom node with selector .patient-participant.template");
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');
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));
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');
319 // invitation text is escaped from innerText
320 this.setNodeInnerText(clonedNode, '.patient-invitation-text', invitation.text || "")
322 console.error("Failed to find invitation data for patient ", p);
323 btnInvitationCopy.classList.add('d-none');
324 btnLinkCopy.classList.add('d-none');
327 templateNode.parentNode.appendChild(clonedNode);
330 this.__updateParticipants = false;
334 showNewPatientScreen() {
335 this.showSecondaryScreen('create-patient');
338 showSearchPatientScreen() {
339 this.showSecondaryScreen('search-patient');
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");
350 primaryScreen.classList.add('d-none');
352 let screen = this.container.querySelector(selector);
354 screen.classList.remove('d-none');
356 console.error("Failed to find selector for add-patient-dialog container " + selector);
358 this.__currentScreen = screenName;
362 getInputValues(screen, inputs) {
363 let inputValues = {};
364 inputs.forEach((i) => {
365 let node = this.container.querySelector("." + screen + ' input[name="' + i + '"]');
367 inputValues[i] = node.value;
369 console.error("Failed to find input node with name " + i);
370 inputValues[i] = null;
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})
382 console.error(error);
383 this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
384 // show the search results
385 this.displaySearchList(true);
389 displaySearchList(shouldDisplay) {
390 let selector = ".search-patient .search-patient-list";
391 let patientList = this.container.querySelector(selector);
393 console.error("Failed to find ",selector);
397 patientList.classList.remove('d-none');
399 patientList.classList.add('d-none');
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);
408 console.error("Failed to find ",selector);
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;
433 this.setNodeInnerText(clonedNode, '.dob', birthDate);
436 let name = (resource.name || []).find(n => n.use == 'official');
438 this.setNodeInnerText(clonedNode, '.fname', name.given.join(" "));
439 this.setNodeInnerText(clonedNode, '.lname', name.family);
442 let email = (resource.telecom || []).find(t => t.system == 'email');
444 this.setNodeInnerText(clonedNode, '.email', email.value);
446 clonedNode.classList.add('missing-email');
450 clonedNode.querySelector('.btn-select-patient').addEventListener('click', () => {
451 this.inviteSearchResultPatient(pidValue);
454 clonedNode.classList.remove('d-none');
458 setNodeInnerText(node, selector, value) {
459 let subNode = node.querySelector(selector);
461 console.error("Failed to find node with selector " + selector);
464 subNode.textContent = value;
467 searchParticipantsAction()
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)
475 this.toggleActionButton(true);
477 this.populateSearchResults(result);
480 this.populateSearchResults([]);
481 let resultMessage = this.__translations.SEARCH_RESULTS_NOT_FOUND;
485 this.toggleActionButton(true);
486 console.error(error);
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)
499 this.toggleActionButton(true);
502 this.toggleActionButton(true);
503 console.error(error);
504 this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
508 toggleActionButton(enabled) {
509 let btns = this.container.querySelectorAll('.btn-create-patient,.btn-invite-search');
510 btns.forEach(b => b.disabled = !enabled);
513 handleSaveParticipantErrorResponse(json) {
514 // need to display the error message.
517 if (json.fields.DOB) {
518 message.push(this.__translations.PATIENT_CREATE_INVALID_DOB);
520 if (json.fields.email) {
521 message.push(this.__translations.PATIENT_CREATE_INVALID_EMAIL);
523 if (json.fields.fname || json.fields.lname) {
524 message.push(this.__translations.PATIENT_CREATE_INVALID_NAME);
526 this.showActionAlert('danger', message.join(". "));
528 this.showActionAlert('danger', this.__translations.OPERATION_FAILED);
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');
538 a.parentNode.removeChild(a);
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();
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();
581 copyPatientLinkToClipboard(evt) {
582 let target = evt.currentTarget;
584 console.error("Failed to get a dom node cannot proceed with copy");
588 let id = target.dataset['inviteId'];
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);
595 let participant = this.__participantList.find(pl => pl.id == id);
597 let invitation = participant.invitation || {};
598 this.copyTextToClipboard(invitation.link || "");
600 this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
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,
613 'Content-Type': 'application/json'
615 ,body: JSON.stringify(postData)
619 if (result.status == 400 || result.status == 401 || (result.ok && result.status == 200)) {
620 return result.json();
622 throw new Error("Failed to generate participant link for participant " + this.pid
623 + " with save data for session " + this.pc_eid);
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)
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);
646 patient.invitation = invitation;
647 this.__updateParticipants = true;
648 this.updateParticipantControls();
649 this.showActionAlert('success', this.__translations.PATIENT_INVITATION_GENERATED);
651 this.showActionAlert('danger', this.__translations.PATIENT_INVITATION_FAILURE);
654 this.showActionAlert('danger', this.__translations.PATIENT_INVITATION_FAILURE);
658 console.error(error);
659 this.showActionAlert('danger', this.__translations.PATIENT_INVITATION_FAILURE);
663 copyPatientInvitationToClipboard(evt) {
664 let target = evt.target;
666 console.error("Failed to get a dom node cannot proceed with copy");
670 let closest = target.closest(".patient-participant[data-pid]");
672 this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
673 throw new Error("Failed to find patient to copy invitation");
675 let invitation = closest.querySelector(".patient-invitation-text");
677 this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
678 throw new Error("Failed to find invitation text with selector .patient-invitation-text");
680 let text = invitation.textContent;
681 this.copyTextToClipboard(text);
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);
692 console.error(error);
693 this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
696 console.error("clipboard.writeText does not exist");
697 this.showActionAlert('danger', this.__translations.CLIPBOARD_COPY_FAILURE);
701 addActionToButton(selector, action) {
702 let btns = this.container.querySelectorAll(selector);
703 for (var i = 0; i < btns.length; i++)
705 btns[i].addEventListener('click', action);
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;