Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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': [
Expand Down
40 changes: 40 additions & 0 deletions contrib/emr-config/config.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, LocaleData>;

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<string, {
searchField: string;
statusField: string;
value: any;
}>;
localesConfig?: LocalesConfig;
defaultLocale?: string;
};

export default config;
21 changes: 21 additions & 0 deletions contrib/emr-config/config.js
Original file line number Diff line number Diff line change
@@ -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 };
8 changes: 8 additions & 0 deletions contrib/emr-config/package.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"workspaces": {
"packages": [
"web",
"shared"
"shared",
"contrib/*"
],
"nohoist": [
"**/react-native",
Expand Down
3 changes: 3 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
9 changes: 9 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
174 changes: 174 additions & 0 deletions web/src/components/BedaFormsRenderer/index.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLIFrameElement>(null);
const pendingResponses = useRef(new Map<string, (response: any) => 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<any> => {
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<void>((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 (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{!isReady && (
<div
style={{
padding: '12px 16px',
fontSize: '13px',
color: '#666',
borderBottom: '1px solid #e0e0e0',
}}
>
Connecting to Beda Forms…
</div>
)}
<iframe
ref={iframeRef}
src={iframeSrc}
onLoad={onIframeLoad}
style={{ flex: 1, width: '100%', border: 'none', minHeight: 0 }}
allow="*"
title="Beda Forms Renderer"
/>
</div>
);
}
4 changes: 2 additions & 2 deletions web/src/components/Cell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import classNames from 'classnames';

import s from './Cell.module.scss';

interface CellProps extends React.HTMLAttributes<HTMLDivElement> {
title?: string;
interface CellProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title'> {
title?: React.ReactNode;
even?: boolean;
}

Expand Down
Loading
Loading