diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d1ffb4f..91522d5 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -43,7 +43,7 @@ module.exports = { 'import/no-unresolved': [ 2, { - ignore: ['fhir/r4b'], // Fixes error: Unable to resolve path to module 'fhir/r4b'. + ignore: ['fhir/r4b', '@beda.software/web-item-controls/controls'], }, ], 'import/order': [ diff --git a/contrib/emr-config/config.d.ts b/contrib/emr-config/config.d.ts new file mode 100644 index 0000000..38d2b01 --- /dev/null +++ b/contrib/emr-config/config.d.ts @@ -0,0 +1,40 @@ +import type { Messages } from '@lingui/core'; +import type { Locale as AntdLocale } from 'antd/es/locale'; + +export interface LocaleData { + label: string; + messages: Messages; + antdLocale: AntdLocale; +} + +export type LocalesConfig = Record; + +declare const config: { + clientId: string; + authTokenPath?: string; + authClientRedirectURL?: string; + + wearablesAccessConsentCodingSystem: string; + + tier: string; + baseURL: string; + fhirBaseURL?: string; + sdcIdeUrl: string; + + sdcBackendUrl: string | null; + webSentryDSN: string | null; + mobileSentryDSN: string | null; + jitsiMeetServer: string; + wearablesDataStreamService: string; + metriportIdentifierSystem: string; + aiAssistantServiceUrl: string; + inactiveMapping?: Record; + localesConfig?: LocalesConfig; + defaultLocale?: string; +}; + +export default config; diff --git a/contrib/emr-config/config.js b/contrib/emr-config/config.js new file mode 100644 index 0000000..518811c --- /dev/null +++ b/contrib/emr-config/config.js @@ -0,0 +1,21 @@ +const config = { + clientId: 'web', + + wearablesAccessConsentCodingSystem: 'https://fhir.emr.beda.software/CodeSystem/consent-subject', + + tier: 'develop', + baseURL: 'http://localhost:8080', + sdcIdeUrl: 'http://localhost:3001', + sdcBackendUrl: null, + + webSentryDSN: null, + mobileSentryDSN: null, + + jitsiMeetServer: 'localhost:8443', + + wearablesDataStreamService: 'http://localhost:8082/api/v1', + metriportIdentifierSystem: 'https://api.sandbox.metriport.com', + aiAssistantServiceUrl: 'http://localhost:3002/', +}; + +export { config as default }; diff --git a/contrib/emr-config/package.json b/contrib/emr-config/package.json new file mode 100644 index 0000000..b05d981 --- /dev/null +++ b/contrib/emr-config/package.json @@ -0,0 +1,8 @@ +{ + "name": "@beda.software/emr-config", + "version": "0.0.0", + "type": "module", + "module": "./config.js", + "main": "./config.js", + "files": ["config.js", "config.d.ts"] +} diff --git a/package.json b/package.json index 1846000..ef46454 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "workspaces": { "packages": [ "web", - "shared" + "shared", + "contrib/*" ], "nohoist": [ "**/react-native", diff --git a/vite.config.ts b/vite.config.ts index 28f8174..cdadfe7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -21,6 +21,9 @@ export const getBaseConfig = ({ plugins = [], build = {}, test = {} }) => }), ...plugins, ], + resolve: { + dedupe: ['react', 'react-dom', 'react-hook-form', 'sdc-qrf', '@babel/runtime'], + }, define: command === 'build' ? {} : { global: {} }, build: { commonjsOptions: { diff --git a/web/package.json b/web/package.json index d096c7b..c226928 100644 --- a/web/package.json +++ b/web/package.json @@ -36,6 +36,15 @@ "@types/react-toastify": "^4.1.0", "@types/yaml": "^1.9.7", "aidbox-react": "^1.12.0", + "@beda.software/emr-config": "*", + "@beda.software/fhir-questionnaire": "0.1.0-alpha.9", + "@beda.software/remote-data": "^1.1.4", + "@beda.software/web-item-controls": "^0.1.19", + "@lingui/core": "^4.3.0", + "@lingui/react": "^4.3.0", + "antd": "5.24.7", + "react-hook-form": "^7.53.2", + "styled-components": "^6.1.11", "allotment": "^1.20.2", "babel-loader": "8.1.0", "babel-plugin-import": "^1.13.3", diff --git a/web/src/components/BedaFormsRenderer/index.tsx b/web/src/components/BedaFormsRenderer/index.tsx new file mode 100644 index 0000000..f63912c --- /dev/null +++ b/web/src/components/BedaFormsRenderer/index.tsx @@ -0,0 +1,174 @@ +import { Questionnaire, QuestionnaireResponse, Parameters } from 'fhir/r4b'; +import { useEffect, useRef, useState } from 'react'; + +import { questionnaireProfileUrl } from 'shared/src/constants'; + +const BEDA_FORMS_URL = 'https://beda-forms.emr.beda.software'; +const BEDA_FORMS_TAG = 'beda:generated'; + +function normalizeProfile(q: Questionnaire): Questionnaire { + return { ...q, meta: { ...q.meta, profile: [questionnaireProfileUrl] } }; +} + +interface Props { + questionnaire: Questionnaire; + questionnaireResponse: QuestionnaireResponse; + launchContextParameters: Parameters['parameter']; + onChange: (qr: QuestionnaireResponse) => void; +} + +function generateId(): string { + return 'msg-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); +} + +export function BedaFormsRenderer({ questionnaire, questionnaireResponse, onChange }: Props) { + const iframeRef = useRef(null); + const pendingResponses = useRef(new Map void>()); + const messagingHandle = useRef( + 'beda-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9), + ); + const [isReady, setIsReady] = useState(false); + // Track the latest QR that originated from beda-forms (to skip re-pushing it) + const originatedFromBeda = useRef(false); + + const postToRenderer = (message: any) => { + iframeRef.current?.contentWindow?.postMessage(message, BEDA_FORMS_URL); + }; + + const sendRequest = (messageType: string, payload: any, timeout = 5000): Promise => { + return new Promise((resolve, reject) => { + const msgId = generateId(); + const timer = setTimeout(() => { + pendingResponses.current.delete(msgId); + reject(new Error(`[BedaForms] Timeout waiting for: ${messageType}`)); + }, timeout); + pendingResponses.current.set(msgId, (response) => { + clearTimeout(timer); + resolve(response); + }); + postToRenderer({ + messagingHandle: messagingHandle.current, + messageId: msgId, + messageType, + payload, + }); + }); + }; + + const runInitSequence = async () => { + try { + await sendRequest('status.handshake', { protocolVersion: '1.0', fhirVersion: 'R4' }); + await sendRequest('sdc.configure', {}); + await sendRequest('sdc.configureContext', {}); + await sendRequest('sdc.displayQuestionnaire', { + questionnaire: normalizeProfile(questionnaire), + }); + setIsReady(true); + } catch (e) { + console.error('[BedaForms] Init sequence failed:', e); + } + }; + + const handleMessage = (event: MessageEvent) => { + if (event.origin !== BEDA_FORMS_URL) return; + if (event.data?.source?.startsWith('react-devtools-')) return; + + const msg = event.data; + + if ('responseToMessageId' in msg) { + const resolver = pendingResponses.current.get(msg.responseToMessageId); + if (resolver) { + pendingResponses.current.delete(msg.responseToMessageId); + resolver(msg); + } + return; + } + + if ('messageType' in msg && msg.messagingHandle === messagingHandle.current) { + if ( + msg.messageType === 'sdc.ui.changedQuestionnaireResponse' && + msg.payload?.questionnaireResponse + ) { + const qr = msg.payload.questionnaireResponse as QuestionnaireResponse; + const tagged: QuestionnaireResponse = JSON.parse(JSON.stringify(qr)); + if (!tagged.meta) tagged.meta = { tag: [] }; + if (!tagged.meta.tag) tagged.meta.tag = []; + if (!tagged.meta.tag.find((t) => t.code === BEDA_FORMS_TAG)) { + tagged.meta.tag.push({ code: BEDA_FORMS_TAG }); + } + originatedFromBeda.current = true; + onChange(tagged); + } + } + }; + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, []); + + const onIframeLoad = async () => { + // Small delay for React inside the iframe to mount and register its message listener + await new Promise((r) => setTimeout(r, 200)); + setIsReady(false); + await runInitSequence(); + }; + + // Push questionnaire changes into the renderer after init + useEffect(() => { + if (!isReady) return; + setIsReady(false); + sendRequest('sdc.displayQuestionnaire', { questionnaire: normalizeProfile(questionnaire) }) + .then(() => setIsReady(true)) + .catch((e) => console.error('[BedaForms] displayQuestionnaire failed:', e)); + }, [questionnaire]); + + // Push incoming QR changes from other parts of the IDE, skipping those that came from beda itself + useEffect(() => { + if (!isReady) return; + if (originatedFromBeda.current) { + originatedFromBeda.current = false; + return; + } + const qr: QuestionnaireResponse = JSON.parse(JSON.stringify(questionnaireResponse)); + delete qr.meta; + qr.status = 'in-progress'; + sendRequest('sdc.displayQuestionnaireResponse', { questionnaireResponse: qr }).catch((e) => + console.error('[BedaForms] displayQuestionnaireResponse failed:', e), + ); + }, [questionnaireResponse]); + + const iframeSrc = (() => { + const params = new URLSearchParams({ + messaging_handle: messagingHandle.current, + messaging_origin: window.location.origin, + embedded_mode: 'true', + }); + return `${BEDA_FORMS_URL}?${params}`; + })(); + + return ( +
+ {!isReady && ( +
+ Connecting to Beda Forms… +
+ )} +