From 4a9fa708f6abfc38a60da05443c6a184a356dd34 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 14:36:56 +0930 Subject: [PATCH 01/18] Add FML BMI calculation test QR and map --- .../bmi-calculation/fhirmapping.fml | 33 +++++++++++++ .../bmi-calculation/qr.json | 47 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml create mode 100644 scripts/FhirMappingLanguage/bmi-calculation/qr.json diff --git a/scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml b/scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml new file mode 100644 index 00000000..b9f12816 --- /dev/null +++ b/scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml @@ -0,0 +1,33 @@ +map "https://smartforms.csiro.au/ig/StructureMap/extract-cvd" = "extract-patient" + +uses "http://hl7.org/fhir/StructureDefinition/QuestionnaireResponse" as source +uses "http://hl7.org/fhir/StructureDefinition/Bundle" as target + +// Used to create a Bundle. +group patientMap(source src : QuestionnaireResponse, target bundle : Bundle) { + // Create bundle id and type + src -> bundle.id = uuid(); + src -> bundle.type = 'transaction'; + + // Create bundle entries + src -> bundle.entry as entry then { + // Create entry.request + src -> entry.request as request, request.method = 'POST', request.url = 'Observation'; + + // Create entry.resource in SetObservationData() + src.item as item where linkId = 'bmi-calculation'-> entry then SetObservationData(item, entry); + }; +} + +// Used to create a Observation resource +group SetObservationData(source src, target entry){ + // Create entry.resource as Observation resource + src.item as item where linkId = 'bmi-result' -> entry.resource = create("Observation") as resource then { + // Create resource.status and resource.code + item -> resource.status = "final"; + item -> resource.code = cc("http://snomed.info/sct", "60621009", "Body mass index") as cc; + + // Create resource.valueQuantity + item -> resource.valueQuantity = create("Quantity") as q, q.value = (%item.answer.value), q.unit = "kg/m2", q.system = "http://unitsofmeasure.org", q.code = "kg/m2"; + }; +} diff --git a/scripts/FhirMappingLanguage/bmi-calculation/qr.json b/scripts/FhirMappingLanguage/bmi-calculation/qr.json new file mode 100644 index 00000000..b7987b87 --- /dev/null +++ b/scripts/FhirMappingLanguage/bmi-calculation/qr.json @@ -0,0 +1,47 @@ +{ + "resourceType": "QuestionnaireResponse", + "status": "in-progress", + "questionnaire": "https://smartforms.csiro.au/docs/sdc/population/calculated-expression-1|0.1.0", + "item": [ + { + "linkId": "bmi-calculation", + "text": "BMI Calculation", + "item": [ + { + "linkId": "patient-height", + "answer": [ + { + "valueDecimal": 163 + } + ], + "text": "Height" + }, + { + "linkId": "patient-weight", + "answer": [ + { + "valueDecimal": 77.3 + } + ], + "text": "Weight" + }, + { + "linkId": "bmi-result", + "text": "Value", + "answer": [ + { + "valueDecimal": 29.1 + } + ] + } + ] + } + ], + "subject": { + "type": "Patient", + "reference": "Patient/pat-sf" + }, + "meta": { + "source": "https://smartforms.csiro.au" + } +} From bb9d405529251b730856473d5211e8b51917b117 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 15:08:29 +0930 Subject: [PATCH 02/18] Add initial commit of extract-express --- package-lock.json | 19 +++ services/extract-express/.gitignore | 2 + services/extract-express/Dockerfile | 17 +++ services/extract-express/package.json | 25 ++++ services/extract-express/src/globals.ts | 22 +++ services/extract-express/src/index.ts | 129 ++++++++++++++++++ .../extract-express/src/operationOutcome.ts | 97 +++++++++++++ services/extract-express/src/questionnaire.ts | 63 +++++++++ .../src/questionnaireResponse.ts | 52 +++++++ services/extract-express/src/structureMap.ts | 74 ++++++++++ services/extract-express/tsconfig.json | 32 +++++ 11 files changed, 532 insertions(+) create mode 100644 services/extract-express/.gitignore create mode 100644 services/extract-express/Dockerfile create mode 100644 services/extract-express/package.json create mode 100644 services/extract-express/src/globals.ts create mode 100644 services/extract-express/src/index.ts create mode 100644 services/extract-express/src/operationOutcome.ts create mode 100644 services/extract-express/src/questionnaire.ts create mode 100644 services/extract-express/src/questionnaireResponse.ts create mode 100644 services/extract-express/src/structureMap.ts create mode 100644 services/extract-express/tsconfig.json diff --git a/package-lock.json b/package-lock.json index ae3fe939..4ce2896f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19471,6 +19471,10 @@ "node": ">=0.10.0" } }, + "node_modules/extract-express": { + "resolved": "services/extract-express", + "link": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "license": "MIT" @@ -42583,6 +42587,21 @@ "typescript": "^5.1.6" } }, + "services/extract-express": { + "version": "0.1.0", + "license": "ISC", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/fhir": "^0.0.38", + "@types/node": "^20.14.2", + "typescript": "^5.1.6" + } + }, "services/populate-express": { "version": "1.2.0", "license": "ISC", diff --git a/services/extract-express/.gitignore b/services/extract-express/.gitignore new file mode 100644 index 00000000..491fc359 --- /dev/null +++ b/services/extract-express/.gitignore @@ -0,0 +1,2 @@ +node_modules +lib diff --git a/services/extract-express/Dockerfile b/services/extract-express/Dockerfile new file mode 100644 index 00000000..b48d4070 --- /dev/null +++ b/services/extract-express/Dockerfile @@ -0,0 +1,17 @@ +FROM node:19 + +WORKDIR /usr/share/app + +COPY services/extract-express services/extract-express +COPY package*.json . + +RUN npm ci + +FROM node:19 + +COPY --from=0 /usr/share/app/services/assemble-express/lib /usr/share/app +COPY --from=0 /usr/share/app/node_modules /usr/share/app/node_modules + +CMD ["node", "/usr/share/app/index.js"] + +EXPOSE 3003 diff --git a/services/extract-express/package.json b/services/extract-express/package.json new file mode 100644 index 00000000..080c2db9 --- /dev/null +++ b/services/extract-express/package.json @@ -0,0 +1,25 @@ +{ + "name": "extract-express", + "version": "0.1.0", + "description": "", + "main": "lib/index.js", + "scripts": { + "compile": "tsc", + "start": "node lib/index.js", + "start:watch": "node --inspect --watch lib/index.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "express": "^4.19.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/cors": "^2.8.17", + "@types/fhir": "^0.0.38", + "@types/node": "^20.14.2", + "typescript": "^5.1.6" + } +} diff --git a/services/extract-express/src/globals.ts b/services/extract-express/src/globals.ts new file mode 100644 index 00000000..d6229654 --- /dev/null +++ b/services/extract-express/src/globals.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const HEADERS = { + 'Cache-Control': 'no-cache', + 'Content-Type': 'application/fhir+json;charset=utf-8', + Accept: 'application/json;charset=utf-8' +}; diff --git a/services/extract-express/src/index.ts b/services/extract-express/src/index.ts new file mode 100644 index 00000000..fcd1e4b6 --- /dev/null +++ b/services/extract-express/src/index.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import express from 'express'; +import cors from 'cors'; +import { getQuestionnaireResponse } from './questionnaireResponse'; +import { + createInvalidParametersOutcome, + createInvalidQuestionnaireCanonicalOutcome, + createNoQuestionnairesFoundOutcome, + createNoTargetStructureMapCanonicalFoundOutcome, + createNoTargetStructureMapFoundOutcome +} from './operationOutcome'; +import { getQuestionnaire } from './questionnaire'; +import { getTargetStructureMap, getTargetStructureMapCanonical } from './structureMap'; + +const app = express(); +const port = 3003; + +app.use( + cors({ + origin: '*' + }) +); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true })); + +app.get('/fhir/QuestionnaireResponse/\\$extract', (_, res) => { + res.send( + 'This service is healthy!\nPerform a POST request to the same path for structureMap $transform.' + ); +}); + +// This $extract operation is hardcoded to use resources and $transform operations from fixed endpoints +// const EHR_SERVER_URL = 'https://proxy.smartforms.io/fhir'; +const FORMS_SERVER_URL = 'https://smartforms.csiro.au/api/fhir'; +app.post('/fhir/QuestionnaireResponse/\\$extract', async (req, res) => { + const body = req.body; + + const questionnaireResponse = getQuestionnaireResponse(body); + + // Get QR resource from input parameters + if (!questionnaireResponse) { + const outcome = createInvalidParametersOutcome(); + res.status(400).json(outcome); + return; + } + + // Get Questionnaire canonical URL from QR resource + const questionnaireCanonical = questionnaireResponse.questionnaire; + if (!questionnaireCanonical) { + const outcome = createInvalidQuestionnaireCanonicalOutcome(); + res.status(400).json(outcome); + return; + } + + // Get Questionnaire resource from it's canonical URL + const questionnaire = await getQuestionnaire(questionnaireCanonical, FORMS_SERVER_URL); + if (!questionnaire) { + const outcome = createNoQuestionnairesFoundOutcome(questionnaireCanonical, FORMS_SERVER_URL); + res.status(400).json(outcome); + return; + } + + // Get target StructureMap canonical URL from Questionnaire resource + const targetStructureMapCanonical = getTargetStructureMapCanonical(questionnaire); + if (!targetStructureMapCanonical) { + const outcome = createNoTargetStructureMapCanonicalFoundOutcome( + questionnaire.id ?? questionnaireCanonical + ); + res.status(400).json(outcome); + return; + } + + // Get target StructureMap resource from it's canonical URL + const targetStructureMap = await getTargetStructureMap( + targetStructureMapCanonical, + FORMS_SERVER_URL + ); + if (!targetStructureMap) { + const outcome = createNoTargetStructureMapFoundOutcome( + questionnaireCanonical, + FORMS_SERVER_URL + ); + res.status(400).json(outcome); + return; + } + + console.log('Extracting questionnaire response:', questionnaireResponse.id); + console.log('Extracting questionnaire:', questionnaire.id); + console.log('Extracting structure map:', targetStructureMap.id); + const outputParameters = { + resourceType: 'Parameters', + parameter: [ + { + name: 'tsm', + resource: targetStructureMap + }, + { + name: 'qr', + resource: questionnaireResponse + }, + { + name: 'questionnaire', + resource: questionnaire + } + ] + }; + + res.json(outputParameters); +}); + +app.listen(port, () => { + console.log(`Transform Express app listening on port ${port}`); +}); diff --git a/services/extract-express/src/operationOutcome.ts b/services/extract-express/src/operationOutcome.ts new file mode 100644 index 00000000..e6112a33 --- /dev/null +++ b/services/extract-express/src/operationOutcome.ts @@ -0,0 +1,97 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { OperationOutcome } from 'fhir/r4b'; + +export function createInvalidParametersOutcome(): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { text: 'Parameters provided is invalid against the $extract specification.' } + } + ] + }; +} + +export function createInvalidQuestionnaireCanonicalOutcome(): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { text: 'No questionnaire URL found in QuestionnaireResponse.questionnaire.' } + } + ] + }; +} + +export function createNoQuestionnairesFoundOutcome( + questionnaireCanonical: string, + formsServerUrl: string +): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { + text: `No questionnaires found with the canonical url "${questionnaireCanonical}" at the FHIR server ${formsServerUrl}.` + } + } + ] + }; +} + +export function createNoTargetStructureMapCanonicalFoundOutcome( + questionnaireId: string +): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { + text: `Questionnaire ${questionnaireId} doesn't have a "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap" extension or it's valueCanonical is empty.` + } + } + ] + }; +} + +export function createNoTargetStructureMapFoundOutcome( + targetStructureMapCanonical: string, + formsServerUrl: string +): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { + text: `No structure maps found with the canonical url "${targetStructureMapCanonical}" at the FHIR server ${formsServerUrl}.` + } + } + ] + }; +} diff --git a/services/extract-express/src/questionnaire.ts b/services/extract-express/src/questionnaire.ts new file mode 100644 index 00000000..36d0e1c8 --- /dev/null +++ b/services/extract-express/src/questionnaire.ts @@ -0,0 +1,63 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Bundle, BundleEntry, Questionnaire } from 'fhir/r4b'; +import { HEADERS } from './globals'; + +export async function getQuestionnaire( + questionnaireCanonical: string, + formsServerUrl: string +): Promise { + questionnaireCanonical = questionnaireCanonical.replace('|', '&version='); + + const requestUrl = `${formsServerUrl}/Questionnaire?url=${questionnaireCanonical}&_sort=_lastUpdated`; + const response = await fetch(requestUrl, { headers: HEADERS }); + + if (!response.ok) { + return null; + } + + const result = await response.json(); + if (resultIsQuestionnaireOrBundle(result)) { + if (result.resourceType === 'Questionnaire') { + return result; + } + + if (result.resourceType === 'Bundle') { + const firstQuestionnaire = result.entry + ?.filter( + (entry): entry is BundleEntry => + entry.resource?.resourceType === 'Questionnaire' + ) + .map((entry) => entry.resource) + .find((questionnaire) => !!questionnaire); + + if (firstQuestionnaire) { + return firstQuestionnaire; + } + } + } + + return null; +} + +function resultIsQuestionnaireOrBundle(result: any): result is Questionnaire | Bundle { + return ( + result.resourceType && + (result.resourceType === 'Questionnaire' || result.resourceType === 'Bundle') + ); +} diff --git a/services/extract-express/src/questionnaireResponse.ts b/services/extract-express/src/questionnaireResponse.ts new file mode 100644 index 00000000..39110a6e --- /dev/null +++ b/services/extract-express/src/questionnaireResponse.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Parameters, ParametersParameter, QuestionnaireResponse } from 'fhir/r4b'; + +export function getQuestionnaireResponse(body: any): QuestionnaireResponse | null { + if (isQuestionnaireResponse(body)) { + return body; + } + + if (isInputParameters(body)) { + return body.parameter[0].resource; + } + + return null; +} + +export function isQuestionnaireResponse(body: any): body is QuestionnaireResponse { + return body.resourceType && body.resourceType === 'QuestionnaireResponse'; +} + +export function isInputParameters(body: any): body is ExtractInputParameters { + return ( + body.resourceType && + body.resourceType === 'Parameters' && + body.parameter[0].name === 'questionnaire-response' && + isQuestionnaireResponse(body.parameter[0].resource) + ); +} + +export interface ExtractInputParameters extends Parameters { + parameter: [QuestionnaireResponseParameter]; +} + +interface QuestionnaireResponseParameter extends ParametersParameter { + name: 'questionnaire-response'; + resource: QuestionnaireResponse; +} diff --git a/services/extract-express/src/structureMap.ts b/services/extract-express/src/structureMap.ts new file mode 100644 index 00000000..19868213 --- /dev/null +++ b/services/extract-express/src/structureMap.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Bundle, BundleEntry, Questionnaire, StructureMap } from 'fhir/r4b'; +import { HEADERS } from './globals'; + +export function getTargetStructureMapCanonical(questionnaire: Questionnaire): string | null { + return ( + questionnaire.extension?.find( + (extension) => + extension.url === + 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap' && + !!extension.valueCanonical + )?.valueCanonical ?? null + ); +} + +export async function getTargetStructureMap( + structureMapCanonical: string, + formsServerUrl: string +): Promise { + structureMapCanonical = structureMapCanonical.replace('|', '&version='); + + const requestUrl = `${formsServerUrl}/StructureMap?url=${structureMapCanonical}&_sort=_lastUpdated`; + const response = await fetch(requestUrl, { headers: HEADERS }); + + if (!response.ok) { + return null; + } + + const result = await response.json(); + if (resultIsStructureMapOrBundle(result)) { + if (result.resourceType === 'StructureMap') { + return result; + } + + if (result.resourceType === 'Bundle') { + const firstStructureMap = result.entry + ?.filter( + (entry): entry is BundleEntry => + entry.resource?.resourceType === 'StructureMap' + ) + .map((entry) => entry.resource) + .find((structureMap) => !!structureMap); + + if (firstStructureMap) { + return firstStructureMap; + } + } + } + + return null; +} + +function resultIsStructureMapOrBundle(result: any): result is StructureMap | Bundle { + return ( + result.resourceType && + (result.resourceType === 'StructureMap' || result.resourceType === 'Bundle') + ); +} diff --git a/services/extract-express/tsconfig.json b/services/extract-express/tsconfig.json new file mode 100644 index 00000000..4d7ace92 --- /dev/null +++ b/services/extract-express/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "CommonJS", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "moduleResolution": "node", + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "jsx": "react", + "sourceMap": true, + "allowJs": true, + "outDir": "lib", + "declaration": true, + "checkJs": true, + "resolveJsonModule": true, + + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "exactOptionalPropertyTypes": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noPropertyAccessFromIndexSignature": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "lib"] +} From c1c67a937469c849bfa64b2221836b533cfbfa09 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 15:10:01 +0930 Subject: [PATCH 03/18] Refine BMI FML, structureMap and $transform JSON body --- .../bmi-calculation/fhirmapping.fml | 29 +- .../bmi-calculation/structuremap.json | 338 +++++++++++++++ .../bmi-calculation/transform-body.json | 396 ++++++++++++++++++ 3 files changed, 748 insertions(+), 15 deletions(-) create mode 100644 scripts/FhirMappingLanguage/bmi-calculation/structuremap.json create mode 100644 scripts/FhirMappingLanguage/bmi-calculation/transform-body.json diff --git a/scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml b/scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml index b9f12816..8503a11a 100644 --- a/scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml +++ b/scripts/FhirMappingLanguage/bmi-calculation/fhirmapping.fml @@ -13,21 +13,20 @@ group patientMap(source src : QuestionnaireResponse, target bundle : Bundle) { src -> bundle.entry as entry then { // Create entry.request src -> entry.request as request, request.method = 'POST', request.url = 'Observation'; - - // Create entry.resource in SetObservationData() - src.item as item where linkId = 'bmi-calculation'-> entry then SetObservationData(item, entry); - }; -} + src -> entry.resource = create("Observation") as resource then { + // Create resource.status + src -> resource.status = "final"; + + // Create resource.code via cc() + src -> resource.code = cc("http://snomed.info/sct", "60621009", "Body mass index") as cc; + + // Create resource.subject with QR's subject + src.subject as sub -> resource.subject = create("Reference") as r, r.reference = (%sub.reference); -// Used to create a Observation resource -group SetObservationData(source src, target entry){ - // Create entry.resource as Observation resource - src.item as item where linkId = 'bmi-result' -> entry.resource = create("Observation") as resource then { - // Create resource.status and resource.code - item -> resource.status = "final"; - item -> resource.code = cc("http://snomed.info/sct", "60621009", "Body mass index") as cc; - - // Create resource.valueQuantity - item -> resource.valueQuantity = create("Quantity") as q, q.value = (%item.answer.value), q.unit = "kg/m2", q.system = "http://unitsofmeasure.org", q.code = "kg/m2"; + // Create resource.valueQuantity + src.item as bmiCalculation where linkId = 'bmi-calculation' -> resource then { + bmiCalculation.item as bmiResult where linkId = 'bmi-result' -> resource.valueQuantity = create("Quantity") as q, q.value = (%bmiResult.answer.value), q.unit = "kg/m2", q.system = "http://unitsofmeasure.org", q.code = "kg/m2"; + }; + }; }; } diff --git a/scripts/FhirMappingLanguage/bmi-calculation/structuremap.json b/scripts/FhirMappingLanguage/bmi-calculation/structuremap.json new file mode 100644 index 00000000..fa9d3d35 --- /dev/null +++ b/scripts/FhirMappingLanguage/bmi-calculation/structuremap.json @@ -0,0 +1,338 @@ +{ + "resourceType": "StructureMap", + "id": "extract-bmi", + "url": "https://smartforms.csiro.au/docs/StructureMap/extract-bmi", + "name": "extract-bmi", + "status": "draft", + "structure": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/QuestionnaireResponse", + "mode": "source" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/Bundle", + "mode": "target", + "documentation": "Used to create a Bundle." + } + ], + "group": [ + { + "name": "patientMap", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "e344ebee86904beeb9eaa4d352a44d0e", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ], + "documentation": "Create bundle id and type" + }, + { + "name": "009e4ea044d6413b82a2dfdea7241777", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "type", + "transform": "copy", + "parameter": [ + { + "valueString": "transaction" + } + ] + } + ] + }, + { + "name": "966928a25a514e788cd35cf30beae8d8", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + } + ], + "rule": [ + { + "name": "817bc3d51af2494aae4267b7954c530c", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "entry", + "contextType": "variable", + "element": "request", + "variable": "request" + }, + { + "context": "request", + "contextType": "variable", + "element": "method", + "transform": "copy", + "parameter": [ + { + "valueString": "POST" + } + ] + }, + { + "context": "request", + "contextType": "variable", + "element": "url", + "transform": "copy", + "parameter": [ + { + "valueString": "Observation" + } + ] + } + ], + "documentation": "Create entry.request" + }, + { + "name": "a6e4ee4234604057a521c0d55b44fcd8", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "resource", + "transform": "create", + "parameter": [ + { + "valueString": "Observation" + } + ] + } + ], + "rule": [ + { + "name": "87b02f7f4e68470490a6a43c06603168", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "status", + "transform": "copy", + "parameter": [ + { + "valueString": "final" + } + ] + } + ], + "documentation": "Create resource.status" + }, + { + "name": "c8ed214a935441ac8322b0efec31665c", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "code", + "variable": "cc", + "transform": "cc", + "parameter": [ + { + "valueString": "http://snomed.info/sct" + }, + { + "valueString": "60621009" + }, + { + "valueString": "Body mass index" + } + ] + } + ], + "documentation": "Create resource.code via cc()" + }, + { + "name": "subject", + "source": [ + { + "context": "src", + "element": "subject", + "variable": "sub" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "subject", + "variable": "r", + "transform": "create", + "parameter": [ + { + "valueString": "Reference" + } + ] + }, + { + "context": "r", + "contextType": "variable", + "element": "reference", + "transform": "evaluate", + "parameter": [ + { + "valueString": "%sub.reference" + } + ] + } + ], + "documentation": "Create resource.subject with QR's subject" + }, + { + "name": "item", + "source": [ + { + "context": "src", + "element": "item", + "variable": "bmiCalculation", + "condition": "linkId = 'bmi-calculation'" + } + ], + "target": [ + { + "transform": "copy", + "parameter": [ + { + "valueId": "resource" + } + ] + } + ], + "rule": [ + { + "name": "item", + "source": [ + { + "context": "bmiCalculation", + "element": "item", + "variable": "bmiResult", + "condition": "linkId = 'bmi-result'" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "valueQuantity", + "variable": "q", + "transform": "create", + "parameter": [ + { + "valueString": "Quantity" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "value", + "transform": "evaluate", + "parameter": [ + { + "valueString": "%bmiResult.answer.value" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "unit", + "transform": "copy", + "parameter": [ + { + "valueString": "kg/m2" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "system", + "transform": "copy", + "parameter": [ + { + "valueString": "http://unitsofmeasure.org" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "code", + "transform": "copy", + "parameter": [ + { + "valueString": "kg/m2" + } + ] + } + ] + } + ], + "documentation": "Create resource.valueQuantity" + } + ] + } + ], + "documentation": "Create bundle entries" + } + ] + } + ] +} diff --git a/scripts/FhirMappingLanguage/bmi-calculation/transform-body.json b/scripts/FhirMappingLanguage/bmi-calculation/transform-body.json new file mode 100644 index 00000000..4dab1f7d --- /dev/null +++ b/scripts/FhirMappingLanguage/bmi-calculation/transform-body.json @@ -0,0 +1,396 @@ +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "source", + "resource": { + "resourceType": "StructureMap", + "id": "extract-patient", + "url": "https://smartforms.csiro.au/ig/StructureMap/extract-cvd", + "name": "extract-patient", + "status": "draft", + "structure": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/QuestionnaireResponse", + "mode": "source" + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/Bundle", + "mode": "target", + "documentation": "Used to create a Bundle." + } + ], + "group": [ + { + "name": "patientMap", + "typeMode": "none", + "input": [ + { + "name": "src", + "type": "QuestionnaireResponse", + "mode": "source" + }, + { + "name": "bundle", + "type": "Bundle", + "mode": "target" + } + ], + "rule": [ + { + "name": "e24cbbcd2119412ea5fd3fda3879aa15", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "id", + "transform": "uuid" + } + ], + "documentation": "Create bundle id and type" + }, + { + "name": "e74313f346cb4011bccf15353b06dde6", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "type", + "transform": "copy", + "parameter": [ + { + "valueString": "transaction" + } + ] + } + ] + }, + { + "name": "b6e3d9d69e5640b5811f79537a56f406", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "bundle", + "contextType": "variable", + "element": "entry", + "variable": "entry" + } + ], + "rule": [ + { + "name": "56baa530bbf949909d28580f4053781c", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "entry", + "contextType": "variable", + "element": "request", + "variable": "request" + }, + { + "context": "request", + "contextType": "variable", + "element": "method", + "transform": "copy", + "parameter": [ + { + "valueString": "POST" + } + ] + }, + { + "context": "request", + "contextType": "variable", + "element": "url", + "transform": "copy", + "parameter": [ + { + "valueString": "Observation" + } + ] + } + ], + "documentation": "Create entry.request" + }, + { + "name": "ee0fc38f536f4df3b69899d0828110fc", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "entry", + "contextType": "variable", + "element": "resource", + "variable": "resource", + "transform": "create", + "parameter": [ + { + "valueString": "Observation" + } + ] + } + ], + "rule": [ + { + "name": "15e13ca4289a4eadbd8cbf6a1304216f", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "status", + "transform": "copy", + "parameter": [ + { + "valueString": "final" + } + ] + } + ], + "documentation": "Create resource.status" + }, + { + "name": "a8073cc67e844c459ab410df81e909e2", + "source": [ + { + "context": "src" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "code", + "variable": "cc", + "transform": "cc", + "parameter": [ + { + "valueString": "http://snomed.info/sct" + }, + { + "valueString": "60621009" + }, + { + "valueString": "Body mass index" + } + ] + } + ], + "documentation": "Create resource.code via cc()" + }, + { + "name": "subject", + "source": [ + { + "context": "src", + "element": "subject", + "variable": "sub" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "subject", + "variable": "r", + "transform": "create", + "parameter": [ + { + "valueString": "Reference" + } + ] + }, + { + "context": "r", + "contextType": "variable", + "element": "reference", + "transform": "evaluate", + "parameter": [ + { + "valueString": "%sub.reference" + } + ] + } + ], + "documentation": "Create resource.subject with QR's subject" + }, + { + "name": "item", + "source": [ + { + "context": "src", + "element": "item", + "variable": "bmiCalculation", + "condition": "linkId = 'bmi-calculation'" + } + ], + "target": [ + { + "transform": "copy", + "parameter": [ + { + "valueId": "resource" + } + ] + } + ], + "rule": [ + { + "name": "item", + "source": [ + { + "context": "bmiCalculation", + "element": "item", + "variable": "bmiResult", + "condition": "linkId = 'bmi-result'" + } + ], + "target": [ + { + "context": "resource", + "contextType": "variable", + "element": "valueQuantity", + "variable": "q", + "transform": "create", + "parameter": [ + { + "valueString": "Quantity" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "value", + "transform": "evaluate", + "parameter": [ + { + "valueString": "%bmiResult.answer.value" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "unit", + "transform": "copy", + "parameter": [ + { + "valueString": "kg/m2" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "system", + "transform": "copy", + "parameter": [ + { + "valueString": "http://unitsofmeasure.org" + } + ] + }, + { + "context": "q", + "contextType": "variable", + "element": "code", + "transform": "copy", + "parameter": [ + { + "valueString": "kg/m2" + } + ] + } + ] + } + ], + "documentation": "Create resource.valueQuantity" + } + ] + } + ], + "documentation": "Create bundle entries" + } + ] + } + ] + } + }, + { + "name": "content", + "resource": { + "resourceType": "QuestionnaireResponse", + "status": "in-progress", + "questionnaire": "https://smartforms.csiro.au/docs/sdc/population/calculated-expression-1|0.1.0", + "item": [ + { + "linkId": "bmi-calculation", + "text": "BMI Calculation", + "item": [ + { + "linkId": "patient-height", + "answer": [ + { + "valueDecimal": 163 + } + ], + "text": "Height" + }, + { + "linkId": "patient-weight", + "answer": [ + { + "valueDecimal": 77.3 + } + ], + "text": "Weight" + }, + { + "linkId": "bmi-result", + "text": "Value", + "answer": [ + { + "valueDecimal": 29.1 + } + ] + } + ] + } + ], + "subject": { + "type": "Patient", + "reference": "Patient/pat-sf" + }, + "meta": { + "source": "https://smartforms.csiro.au" + } + } + } + ] +} From e4a34685632380897a34eff7a4a16af0bfdaf153 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 15:28:07 +0930 Subject: [PATCH 04/18] Return $transform response as $extract response --- services/extract-express/src/index.ts | 49 ++++++------ .../extract-express/src/operationOutcome.ts | 15 ++++ services/extract-express/src/transform.ts | 76 +++++++++++++++++++ 3 files changed, 117 insertions(+), 23 deletions(-) create mode 100644 services/extract-express/src/transform.ts diff --git a/services/extract-express/src/index.ts b/services/extract-express/src/index.ts index fcd1e4b6..a6e7643d 100644 --- a/services/extract-express/src/index.ts +++ b/services/extract-express/src/index.ts @@ -23,10 +23,12 @@ import { createInvalidQuestionnaireCanonicalOutcome, createNoQuestionnairesFoundOutcome, createNoTargetStructureMapCanonicalFoundOutcome, - createNoTargetStructureMapFoundOutcome + createNoTargetStructureMapFoundOutcome, + createOperationOutcome } from './operationOutcome'; import { getQuestionnaire } from './questionnaire'; import { getTargetStructureMap, getTargetStructureMapCanonical } from './structureMap'; +import { createTransformInputParameters, invokeTransform } from './transform'; const app = express(); const port = 3003; @@ -46,7 +48,7 @@ app.get('/fhir/QuestionnaireResponse/\\$extract', (_, res) => { }); // This $extract operation is hardcoded to use resources and $transform operations from fixed endpoints -// const EHR_SERVER_URL = 'https://proxy.smartforms.io/fhir'; +const EHR_SERVER_URL = 'https://proxy.smartforms.io/fhir'; const FORMS_SERVER_URL = 'https://smartforms.csiro.au/api/fhir'; app.post('/fhir/QuestionnaireResponse/\\$extract', async (req, res) => { const body = req.body; @@ -100,28 +102,29 @@ app.post('/fhir/QuestionnaireResponse/\\$extract', async (req, res) => { return; } - console.log('Extracting questionnaire response:', questionnaireResponse.id); - console.log('Extracting questionnaire:', questionnaire.id); - console.log('Extracting structure map:', targetStructureMap.id); - const outputParameters = { - resourceType: 'Parameters', - parameter: [ - { - name: 'tsm', - resource: targetStructureMap - }, - { - name: 'qr', - resource: questionnaireResponse - }, - { - name: 'questionnaire', - resource: questionnaire - } - ] - }; + // + const transformInputParameters = createTransformInputParameters( + targetStructureMap, + questionnaireResponse + ); - res.json(outputParameters); + try { + const outputParameters = await invokeTransform(transformInputParameters, EHR_SERVER_URL); + res.json(outputParameters); // Forwarding the JSON response to the client + } catch (error) { + console.error(error); + if (error instanceof Error) { + res.status(500).json(createOperationOutcome(error?.message)); // Sending the error message as JSON response + } else { + res + .status(500) + .json( + createOperationOutcome( + 'Something went wrong here. Please raise a GitHub issue at https://github.com/aehrc/smart-forms/issues/new' + ) + ); + } + } }); app.listen(port, () => { diff --git a/services/extract-express/src/operationOutcome.ts b/services/extract-express/src/operationOutcome.ts index e6112a33..6eacb0f4 100644 --- a/services/extract-express/src/operationOutcome.ts +++ b/services/extract-express/src/operationOutcome.ts @@ -95,3 +95,18 @@ export function createNoTargetStructureMapFoundOutcome( ] }; } + +export function createOperationOutcome(errorMessage: string): OperationOutcome { + return { + resourceType: 'OperationOutcome', + issue: [ + { + severity: 'error', + code: 'invalid', + details: { + text: errorMessage + } + } + ] + }; +} diff --git a/services/extract-express/src/transform.ts b/services/extract-express/src/transform.ts new file mode 100644 index 00000000..8bccc6b5 --- /dev/null +++ b/services/extract-express/src/transform.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + FhirResource, + Parameters, + ParametersParameter, + QuestionnaireResponse, + StructureMap +} from 'fhir/r4b'; +import { HEADERS } from './globals'; + +export function createTransformInputParameters( + targetStructureMap: StructureMap, + questionnaireResponse: QuestionnaireResponse +): TransformInputParameters { + return { + resourceType: 'Parameters', + parameter: [ + { + name: 'source', + resource: targetStructureMap + }, + { + name: 'content', + resource: questionnaireResponse + } + ] + }; +} + +export async function invokeTransform( + transformInputParameters: TransformInputParameters, + ehrServerUrl: string +): Promise { + const requestUrl = `${ehrServerUrl}/StructureMap/$transform`; + const response = await fetch(requestUrl, { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(transformInputParameters) + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); +} + +export interface TransformInputParameters extends Parameters { + parameter: [SourceParameter, ContentParameter]; +} + +interface SourceParameter extends ParametersParameter { + name: 'source'; + resource: StructureMap; +} + +interface ContentParameter extends ParametersParameter { + name: 'content'; + resource: FhirResource; +} From 4ce86bdcdff11f75fb724d9c63e9961a3abeba0c Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 15:40:11 +0930 Subject: [PATCH 05/18] Update extract-express dockerfile --- services/extract-express/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/extract-express/Dockerfile b/services/extract-express/Dockerfile index b48d4070..768827f3 100644 --- a/services/extract-express/Dockerfile +++ b/services/extract-express/Dockerfile @@ -9,7 +9,7 @@ RUN npm ci FROM node:19 -COPY --from=0 /usr/share/app/services/assemble-express/lib /usr/share/app +COPY --from=0 /usr/share/app/services/extract-express/lib /usr/share/app COPY --from=0 /usr/share/app/node_modules /usr/share/app/node_modules CMD ["node", "/usr/share/app/index.js"] From da42d5d2270c258d2c91a94ede9b44024f4b0162 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 16:51:03 +0930 Subject: [PATCH 06/18] Add extract button --- .../components/ExtractButtonForPlayground.tsx | 61 +++++++++++++++++++ .../playground/components/JsonEditor.tsx | 21 ++++++- .../playground/components/Playground.tsx | 44 ++++++++++++- .../components/PlaygroundRenderer.tsx | 8 ++- .../components/PrePopButtonForPlayground.tsx | 2 +- .../components/StoreStateViewer.tsx | 12 +++- .../ExtractedResourceViewer.tsx | 37 +++++++++++ .../RendererNavLaunchQuestionnaireActions.tsx | 5 +- .../RendererNavStandardActions.tsx | 4 +- 9 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx create mode 100644 apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx diff --git a/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx b/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx new file mode 100644 index 00000000..e5ef6c41 --- /dev/null +++ b/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// @ts-ignore +import React from 'react'; +import { CircularProgress, Fade, IconButton, Tooltip } from '@mui/material'; +import Typography from '@mui/material/Typography'; +import Iconify from '../../../components/Iconify/Iconify.tsx'; + +interface ExtractForPlaygroundProps { + isExtracting: boolean; + onExtract: () => void; +} + +function ExtractButtonForPlayground(props: ExtractForPlaygroundProps) { + const { isExtracting, onExtract } = props; + + return ( + <> + + + + {isExtracting ? ( + + ) : ( + + )} + + + + {isExtracting ? ( + + + Performing extraction... + + + ) : null} + + ); +} + +export default ExtractButtonForPlayground; diff --git a/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx b/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx index a7af5c08..76b965da 100644 --- a/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx +++ b/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx @@ -28,12 +28,22 @@ interface Props { jsonString: string; onJsonStringChange: (jsonString: string) => void; buildingState: 'idle' | 'building' | 'built'; + isExtracting: boolean; + extractedResource: any; onBuildForm: (jsonString: string) => unknown; onDestroyForm: () => unknown; } function JsonEditor(props: Props) { - const { jsonString, onJsonStringChange, buildingState, onBuildForm, onDestroyForm } = props; + const { + jsonString, + onJsonStringChange, + buildingState, + isExtracting, + extractedResource, + onBuildForm, + onDestroyForm + } = props; const [view, setView] = useState<'editor' | 'storeState'>('editor'); const [selectedStore, setSelectedStore] = useState('questionnaireResponseStore'); @@ -78,7 +88,7 @@ function JsonEditor(props: Props) { onClick={() => { setView('storeState'); }}> - See store state + See advanced properties ) : ( @@ -99,6 +109,7 @@ function JsonEditor(props: Props) { Q QR Terminology + Extracted )} @@ -131,7 +142,11 @@ function JsonEditor(props: Props) { /> ) : ( - + )} diff --git a/apps/smart-forms-app/src/features/playground/components/Playground.tsx b/apps/smart-forms-app/src/features/playground/components/Playground.tsx index c67db9e0..7ac14f5d 100644 --- a/apps/smart-forms-app/src/features/playground/components/Playground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/Playground.tsx @@ -29,15 +29,17 @@ import PopulationProgressSpinner from '../../../components/Spinners/PopulationPr import { isQuestionnaire } from '../typePredicates/isQuestionnaire.ts'; import type { BuildState } from '../types/buildState.interface.ts'; import { useLocalStorage } from 'usehooks-ts'; -import { buildForm, destroyForm } from '@aehrc/smart-forms-renderer'; +import { buildForm, destroyForm, useQuestionnaireResponseStore } from '@aehrc/smart-forms-renderer'; import RendererDebugFooter from '../../renderer/components/RendererDebugFooter/RendererDebugFooter.tsx'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { TERMINOLOGY_SERVER_URL } from '../../../globals.ts'; import PlaygroundPicker from './PlaygroundPicker.tsx'; import type { Patient, Practitioner, Questionnaire } from 'fhir/r4'; import PlaygroundHeader from './PlaygroundHeader.tsx'; +import { HEADERS } from '../../../api/headers.ts'; const defaultFhirServerUrl = 'https://hapi.fhir.org/baseR4'; +const defaultExtractEndpoint = 'https://proxy.smartforms.io/fhir'; function Playground() { const [fhirServerUrl, setFhirServerUrl] = useLocalStorage( @@ -49,6 +51,12 @@ function Playground() { const [jsonString, setJsonString] = useLocalStorage('playgroundJsonString', ''); const [buildingState, setBuildingState] = useState('idle'); + // $extract-related states + const [isExtracting, setExtracting] = useState(false); + const [extractedResource, setExtractedResource] = useState(null); + + const updatableResponse = useQuestionnaireResponseStore.use.updatableResponse(); + const { enqueueSnackbar } = useSnackbar(); function handleDestroyForm() { @@ -131,6 +139,30 @@ function Playground() { }; } + // $extract + async function handleExtract() { + setExtracting(true); + + const response = await fetch(defaultExtractEndpoint + '/QuestionnaireResponse/$extract', { + method: 'POST', + headers: HEADERS, + body: JSON.stringify(updatableResponse) + }); + setExtracting(false); + + if (!response.ok) { + enqueueSnackbar('Failed to extract resource', { + variant: 'error', + preventDuplicate: true, + action: + }); + setExtractedResource(null); + } else { + const extractedResource = response.json(); + setExtractedResource(extractedResource); + } + } + return ( <> {buildingState === 'built' ? ( - + ) : buildingState === 'building' ? ( ) : ( @@ -167,6 +205,8 @@ function Playground() { jsonString={jsonString} onJsonStringChange={(jsonString: string) => setJsonString(jsonString)} buildingState={buildingState} + isExtracting={isExtracting} + extractedResource={extractedResource} onBuildForm={handleBuildQuestionnaireFromString} onDestroyForm={handleDestroyForm} /> diff --git a/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx b/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx index d2dd7f69..b1802c68 100644 --- a/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx +++ b/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx @@ -24,15 +24,18 @@ import type { Patient, Practitioner } from 'fhir/r4'; import { Box, Typography } from '@mui/material'; import useLaunchContextNames from '../hooks/useLaunchContextNames.ts'; import { TERMINOLOGY_SERVER_URL } from '../../../globals.ts'; +import ExtractButtonForPlayground from './ExtractButtonForPlayground.tsx'; interface PlaygroundRendererProps { endpointUrl: string | null; patient: Patient | null; user: Practitioner | null; + isExtracting: boolean; + onExtract: () => void; } function PlaygroundRenderer(props: PlaygroundRendererProps) { - const { endpointUrl, patient, user } = props; + const { endpointUrl, patient, user, isExtracting, onExtract } = props; const sourceQuestionnaire = useQuestionnaireStore.use.sourceQuestionnaire(); @@ -76,8 +79,9 @@ function PlaygroundRenderer(props: PlaygroundRendererProps) { return ( <> {prePopEnabled ? ( - + + {patientName ? ( diff --git a/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx b/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx index 848e5e88..984aca93 100644 --- a/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx @@ -31,7 +31,7 @@ function PrePopButtonForPlayground(props: PrePopButtonForPlaygroundProps) { return ( <> - + ; @@ -51,6 +55,12 @@ function StoreStateViewer(props: StoreStateViewerProps) { return ; } + if (selectedStore === 'extractedResource') { + return ( + + ); + } + return No store selected; } diff --git a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx new file mode 100644 index 00000000..a90f2a97 --- /dev/null +++ b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import GenericStatePropertyPicker from './GenericStatePropertyPicker.tsx'; +import GenericViewer from './GenericViewer.tsx'; + +const extractedSectionPropertyNames: string[] = ['extracted']; + +interface ExtractedSectionViewerProps { + isExtracting: boolean; + extractedResource: any; +} + +function ExtractedSectionViewer(props: ExtractedSectionViewerProps) { + const { isExtracting, extractedResource } = props; + + const [selectedProperty, setSelectedProperty] = useState('extracted'); + const [showJsonTree, setShowJsonTree] = useState(false); + + const propertyObject = isExtracting ? 'Performing extraction...' : extractedResource; + + return ( + <> + + + + ); +} + +export default ExtractedSectionViewer; diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx index b32d3967..8b67cf2a 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Box, List, Typography } from '@mui/material'; +import { Box, List } from '@mui/material'; import { NavSectionHeading } from '../../../../components/Nav/Nav.styles.ts'; import useSmartClient from '../../../../hooks/useSmartClient.ts'; import ViewExistingResponsesAction from '../RendererActions/ViewExistingResponsesAction.tsx'; @@ -57,7 +57,7 @@ function RendererNavLaunchQuestionnaireActions(props: RendererNavLaunchQuestionn - Operations + Operations @@ -66,6 +66,7 @@ function RendererNavLaunchQuestionnaireActions(props: RendererNavLaunchQuestionn + ) : null} diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavStandardActions.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavStandardActions.tsx index c019c073..7b6f760e 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavStandardActions.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavStandardActions.tsx @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Box, List, Typography } from '@mui/material'; +import { Box, List } from '@mui/material'; import { NavSectionHeading } from '../../../../components/Nav/Nav.styles.ts'; import BackToQuestionnairesAction from '../RendererActions/BackToQuestionnairesAction.tsx'; import useSmartClient from '../../../../hooks/useSmartClient.ts'; @@ -51,7 +51,7 @@ function RendererNavStandardActions(props: RendererNavStandardActionsProps) { - Operations + Operations From f020811abfbf90cc55c73b180c3c1e94534f857d Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 17:45:17 +0930 Subject: [PATCH 07/18] Add extract endpoint to ehr app deployment --- .../ehr-proxy-app/lib/ehr-proxy-app-stack.ts | 24 ++++++++- .../ehr-proxy/ehr-proxy-app/package.json | 3 +- .../ehr-proxy/extract-endpoint/.gitignore | 8 +++ .../ehr-proxy/extract-endpoint/.npmignore | 6 +++ .../ehr-proxy/extract-endpoint/README.md | 14 ++++++ .../ehr-proxy/extract-endpoint/jest.config.js | 8 +++ .../ehr-proxy/extract-endpoint/lib/index.ts | 49 +++++++++++++++++++ .../ehr-proxy/extract-endpoint/package.json | 25 ++++++++++ .../ehr-proxy/extract-endpoint/tsconfig.json | 23 +++++++++ package-lock.json | 23 +++++++++ 10 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 deployment/ehr-proxy/extract-endpoint/.gitignore create mode 100644 deployment/ehr-proxy/extract-endpoint/.npmignore create mode 100644 deployment/ehr-proxy/extract-endpoint/README.md create mode 100644 deployment/ehr-proxy/extract-endpoint/jest.config.js create mode 100644 deployment/ehr-proxy/extract-endpoint/lib/index.ts create mode 100644 deployment/ehr-proxy/extract-endpoint/package.json create mode 100644 deployment/ehr-proxy/extract-endpoint/tsconfig.json diff --git a/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts b/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts index 7d1bc770..1716e1bb 100644 --- a/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts +++ b/deployment/ehr-proxy/ehr-proxy-app/lib/ehr-proxy-app-stack.ts @@ -15,6 +15,7 @@ import { LoadBalancerTarget } from 'aws-cdk-lib/aws-route53-targets'; import { HapiEndpoint } from 'ehr-proxy-hapi-endpoint'; import { SmartProxy } from 'ehr-proxy-smart-proxy'; import { TransformEndpoint } from 'ehr-proxy-transform-endpoint'; +import { ExtractEndpoint } from 'ehr-proxy-extract-endpoint'; export class EhrProxyAppStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { @@ -55,6 +56,25 @@ export class EhrProxyAppStack extends cdk.Stack { const hapi = new HapiEndpoint(this, 'EhrProxyHapi', { cluster }); const smartProxy = new SmartProxy(this, 'EhrProxySmartProxy', { cluster }); const transform = new TransformEndpoint(this, 'EhrProxyTransform', { cluster }); + const extract = new ExtractEndpoint(this, 'EhrProxyExtract', { cluster }); + + // Create a target for the extract service + const extractTarget = extract.service.loadBalancerTarget({ + containerName: extract.containerName, + containerPort: extract.containerPort + }); + const extractTargetGroup = new ApplicationTargetGroup(this, 'EhrProxyExtractTargetGroup', { + vpc, + port: extract.containerPort, + protocol: ApplicationProtocol.HTTP, + targets: [extractTarget], + healthCheck: { path: '/fhir/QuestionnaireResponse/$extract' } + }); + listener.addAction('EhrProxyExtractAction', { + action: ListenerAction.forward([extractTargetGroup]), + priority: 1, + conditions: [ListenerCondition.pathPatterns(['/fhir/QuestionnaireResponse/$extract'])] + }); // Create a target for the transform service const transformTarget = transform.service.loadBalancerTarget({ @@ -70,7 +90,7 @@ export class EhrProxyAppStack extends cdk.Stack { }); listener.addAction('EhrProxyTransformAction', { action: ListenerAction.forward([transformTargetGroup]), - priority: 1, + priority: 2, conditions: [ListenerCondition.pathPatterns(['/fhir/StructureMap/$transform'])] }); @@ -88,7 +108,7 @@ export class EhrProxyAppStack extends cdk.Stack { }); listener.addAction('EhrProxyHapiAction', { action: ListenerAction.forward([hapiTargetGroup]), - priority: 2, + priority: 3, conditions: [ListenerCondition.pathPatterns(['/fhir*'])] }); diff --git a/deployment/ehr-proxy/ehr-proxy-app/package.json b/deployment/ehr-proxy/ehr-proxy-app/package.json index adbe31c6..39fb996b 100644 --- a/deployment/ehr-proxy/ehr-proxy-app/package.json +++ b/deployment/ehr-proxy/ehr-proxy-app/package.json @@ -25,6 +25,7 @@ "source-map-support": "^0.5.21", "ehr-proxy-hapi-endpoint": "^0.1.0", "ehr-proxy-smart-proxy": "^0.1.0", - "ehr-proxy-transform-endpoint": "^0.1.0" + "ehr-proxy-transform-endpoint": "^0.1.0", + "ehr-proxy-extract-endpoint": "^0.1.0" } } diff --git a/deployment/ehr-proxy/extract-endpoint/.gitignore b/deployment/ehr-proxy/extract-endpoint/.gitignore new file mode 100644 index 00000000..f60797b6 --- /dev/null +++ b/deployment/ehr-proxy/extract-endpoint/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/deployment/ehr-proxy/extract-endpoint/.npmignore b/deployment/ehr-proxy/extract-endpoint/.npmignore new file mode 100644 index 00000000..c1d6d45d --- /dev/null +++ b/deployment/ehr-proxy/extract-endpoint/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/deployment/ehr-proxy/extract-endpoint/README.md b/deployment/ehr-proxy/extract-endpoint/README.md new file mode 100644 index 00000000..320efc02 --- /dev/null +++ b/deployment/ehr-proxy/extract-endpoint/README.md @@ -0,0 +1,14 @@ +# Welcome to your CDK TypeScript project + +This is a blank project for CDK development with TypeScript. + +The `cdk.json` file tells the CDK Toolkit how to execute your app. + +## Useful commands + +* `npm run build` compile typescript to js +* `npm run watch` watch for changes and compile +* `npm run test` perform the jest unit tests +* `cdk deploy` deploy this stack to your default AWS account/region +* `cdk diff` compare deployed stack with current state +* `cdk synth` emits the synthesized CloudFormation template diff --git a/deployment/ehr-proxy/extract-endpoint/jest.config.js b/deployment/ehr-proxy/extract-endpoint/jest.config.js new file mode 100644 index 00000000..08263b89 --- /dev/null +++ b/deployment/ehr-proxy/extract-endpoint/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/deployment/ehr-proxy/extract-endpoint/lib/index.ts b/deployment/ehr-proxy/extract-endpoint/lib/index.ts new file mode 100644 index 00000000..1c01c510 --- /dev/null +++ b/deployment/ehr-proxy/extract-endpoint/lib/index.ts @@ -0,0 +1,49 @@ +import { + AwsLogDriver, + Cluster, + Compatibility, + ContainerImage, + FargateService, + TaskDefinition +} from 'aws-cdk-lib/aws-ecs'; +import { Construct } from 'constructs'; +import { RetentionDays } from 'aws-cdk-lib/aws-logs'; + +export interface ExtractEndpointProps { + cluster: Cluster; +} + +export class ExtractEndpoint extends Construct { + containerName = 'ehr-proxy-extract'; + containerPort = 3003; + service: FargateService; + + constructor(scope: Construct, id: string, props: ExtractEndpointProps) { + super(scope, id); + + const { cluster } = props; + + // Create a task definition that contains both the application and cache containers. + const taskDefinition = new TaskDefinition(this, 'EhrProxyExtractTaskDefinition', { + compatibility: Compatibility.FARGATE, + cpu: '256', + memoryMiB: '512' + }); + + // Create the cache container. + taskDefinition.addContainer('EhrProxyExtractContainer', { + containerName: this.containerName, + image: ContainerImage.fromRegistry('aehrc/smart-forms-extract:latest'), + portMappings: [{ containerPort: this.containerPort }], + logging: AwsLogDriver.awsLogs({ + streamPrefix: 'ehr-proxy-extract', + logRetention: RetentionDays.ONE_MONTH + }) + }); + + this.service = new FargateService(this, 'EhrProxyExtractService', { + cluster, + taskDefinition + }); + } +} diff --git a/deployment/ehr-proxy/extract-endpoint/package.json b/deployment/ehr-proxy/extract-endpoint/package.json new file mode 100644 index 00000000..0b203f09 --- /dev/null +++ b/deployment/ehr-proxy/extract-endpoint/package.json @@ -0,0 +1,25 @@ +{ + "name": "ehr-proxy-extract-endpoint", + "version": "0.1.0", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest" + }, + "devDependencies": { + "@types/jest": "^29.5.5", + "@types/node": "20.14.2", + "aws-cdk-lib": "2.104.0", + "constructs": "^10.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "typescript": "~5.2.2" + }, + "dependencies": { + "aws-cdk-lib": "2.104.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + } +} diff --git a/deployment/ehr-proxy/extract-endpoint/tsconfig.json b/deployment/ehr-proxy/extract-endpoint/tsconfig.json new file mode 100644 index 00000000..464ed774 --- /dev/null +++ b/deployment/ehr-proxy/extract-endpoint/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["es2020", "dom"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "cdk.out"] +} diff --git a/package-lock.json b/package-lock.json index 4ce2896f..b35dede6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -272,6 +272,7 @@ "dependencies": { "aws-cdk-lib": "2.104.0", "constructs": "^10.0.0", + "ehr-proxy-extract-endpoint": "^0.1.0", "ehr-proxy-hapi-endpoint": "^0.1.0", "ehr-proxy-smart-proxy": "^0.1.0", "ehr-proxy-transform-endpoint": "^0.1.0", @@ -290,6 +291,24 @@ "typescript": "~5.2.2" } }, + "deployment/ehr-proxy/extract-endpoint": { + "name": "ehr-proxy-extract-endpoint", + "version": "0.1.0", + "dependencies": { + "aws-cdk-lib": "2.104.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + }, + "devDependencies": { + "@types/jest": "^29.5.5", + "@types/node": "20.14.2", + "aws-cdk-lib": "2.104.0", + "constructs": "^10.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "typescript": "~5.2.2" + } + }, "deployment/ehr-proxy/hapi-endpoint": { "name": "ehr-proxy-hapi-endpoint", "version": "0.1.0", @@ -18362,6 +18381,10 @@ "resolved": "deployment/ehr-proxy/ehr-proxy-app", "link": true }, + "node_modules/ehr-proxy-extract-endpoint": { + "resolved": "deployment/ehr-proxy/extract-endpoint", + "link": true + }, "node_modules/ehr-proxy-hapi-endpoint": { "resolved": "deployment/ehr-proxy/hapi-endpoint", "link": true From 694df45a148ae702a1f39f0dd54251aeb608be16 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 17:47:07 +0930 Subject: [PATCH 08/18] Tweak headers to make extract working --- .../src/features/playground/components/Playground.tsx | 4 ++-- services/extract-express/src/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/smart-forms-app/src/features/playground/components/Playground.tsx b/apps/smart-forms-app/src/features/playground/components/Playground.tsx index 7ac14f5d..cce6ec06 100644 --- a/apps/smart-forms-app/src/features/playground/components/Playground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/Playground.tsx @@ -145,7 +145,7 @@ function Playground() { const response = await fetch(defaultExtractEndpoint + '/QuestionnaireResponse/$extract', { method: 'POST', - headers: HEADERS, + headers: { ...HEADERS, 'Content-Type': 'application/json;charset=utf-8' }, body: JSON.stringify(updatableResponse) }); setExtracting(false); @@ -158,7 +158,7 @@ function Playground() { }); setExtractedResource(null); } else { - const extractedResource = response.json(); + const extractedResource = await response.json(); setExtractedResource(extractedResource); } } diff --git a/services/extract-express/src/index.ts b/services/extract-express/src/index.ts index a6e7643d..6b363b34 100644 --- a/services/extract-express/src/index.ts +++ b/services/extract-express/src/index.ts @@ -43,7 +43,7 @@ app.use(express.urlencoded({ extended: true })); app.get('/fhir/QuestionnaireResponse/\\$extract', (_, res) => { res.send( - 'This service is healthy!\nPerform a POST request to the same path for structureMap $transform.' + 'This service is healthy!\nPerform a POST request to the same path for QuestionnaireResponse $extract.' ); }); From 4ea5edc1b64dae49c4071f48f00826afd4d88bd6 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 17:51:44 +0930 Subject: [PATCH 09/18] Fix linting --- services/extract-express/src/operationOutcome.ts | 2 +- services/extract-express/src/questionnaire.ts | 2 +- services/extract-express/src/questionnaireResponse.ts | 2 +- services/extract-express/src/structureMap.ts | 2 +- services/extract-express/src/transform.ts | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/extract-express/src/operationOutcome.ts b/services/extract-express/src/operationOutcome.ts index 6eacb0f4..0fe12f71 100644 --- a/services/extract-express/src/operationOutcome.ts +++ b/services/extract-express/src/operationOutcome.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { OperationOutcome } from 'fhir/r4b'; +import type { OperationOutcome } from 'fhir/r4b'; export function createInvalidParametersOutcome(): OperationOutcome { return { diff --git a/services/extract-express/src/questionnaire.ts b/services/extract-express/src/questionnaire.ts index 36d0e1c8..6ded8344 100644 --- a/services/extract-express/src/questionnaire.ts +++ b/services/extract-express/src/questionnaire.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Bundle, BundleEntry, Questionnaire } from 'fhir/r4b'; +import type { Bundle, BundleEntry, Questionnaire } from 'fhir/r4b'; import { HEADERS } from './globals'; export async function getQuestionnaire( diff --git a/services/extract-express/src/questionnaireResponse.ts b/services/extract-express/src/questionnaireResponse.ts index 39110a6e..bb8113cb 100644 --- a/services/extract-express/src/questionnaireResponse.ts +++ b/services/extract-express/src/questionnaireResponse.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Parameters, ParametersParameter, QuestionnaireResponse } from 'fhir/r4b'; +import type { Parameters, ParametersParameter, QuestionnaireResponse } from 'fhir/r4b'; export function getQuestionnaireResponse(body: any): QuestionnaireResponse | null { if (isQuestionnaireResponse(body)) { diff --git a/services/extract-express/src/structureMap.ts b/services/extract-express/src/structureMap.ts index 19868213..4c0bfbb4 100644 --- a/services/extract-express/src/structureMap.ts +++ b/services/extract-express/src/structureMap.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { Bundle, BundleEntry, Questionnaire, StructureMap } from 'fhir/r4b'; +import type { Bundle, BundleEntry, Questionnaire, StructureMap } from 'fhir/r4b'; import { HEADERS } from './globals'; export function getTargetStructureMapCanonical(questionnaire: Questionnaire): string | null { diff --git a/services/extract-express/src/transform.ts b/services/extract-express/src/transform.ts index 8bccc6b5..fb1ea996 100644 --- a/services/extract-express/src/transform.ts +++ b/services/extract-express/src/transform.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { +import type { FhirResource, Parameters, ParametersParameter, From 72ee25c9d4b4bd31b3ef20416f5757e92558d2a7 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 18:18:08 +0930 Subject: [PATCH 10/18] Fix tests --- apps/smart-forms-app/e2e/_pre-run.spec.ts | 10 ++++++++-- apps/smart-forms-app/e2e/dashboard.spec.ts | 4 ++-- apps/smart-forms-app/e2e/globals.ts | 4 ++++ apps/smart-forms-app/e2e/saving.spec.ts | 4 ++-- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/smart-forms-app/e2e/_pre-run.spec.ts b/apps/smart-forms-app/e2e/_pre-run.spec.ts index ca455639..03265a05 100644 --- a/apps/smart-forms-app/e2e/_pre-run.spec.ts +++ b/apps/smart-forms-app/e2e/_pre-run.spec.ts @@ -16,7 +16,12 @@ */ import { expect, test } from '@playwright/test'; -import { PLAYWRIGHT_APP_URL, PLAYWRIGHT_EHR_URL, PLAYWRIGHT_FORMS_SERVER_URL } from './globals'; +import { + LAUNCH_PARAM, + PLAYWRIGHT_APP_URL, + PLAYWRIGHT_EHR_URL, + PLAYWRIGHT_FORMS_SERVER_URL +} from './globals'; test('launch without questionnaire context, select a questionnaire and create a new response', async ({ page @@ -25,7 +30,8 @@ test('launch without questionnaire context, select a questionnaire and create a const fetchQPromise = page.waitForResponse( `${PLAYWRIGHT_FORMS_SERVER_URL}/Questionnaire?_count=100&_sort=-date&` ); - const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjQxNzMvIiwiYTU3ZDkwZTMtNWY2OS00YjkyLWFhMmUtMjk5MjE4MDg2M2MxIiwiIiwiIiwiIiwiIiwwLDEsIiIsZmFsc2Vd`; + + const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM}`; await page.goto(launchUrl); expect((await fetchQPromise).status()).toBe(200); diff --git a/apps/smart-forms-app/e2e/dashboard.spec.ts b/apps/smart-forms-app/e2e/dashboard.spec.ts index 21377243..01a99177 100644 --- a/apps/smart-forms-app/e2e/dashboard.spec.ts +++ b/apps/smart-forms-app/e2e/dashboard.spec.ts @@ -16,7 +16,7 @@ */ import { expect, test } from '@playwright/test'; -import { PLAYWRIGHT_APP_URL, PLAYWRIGHT_FORMS_SERVER_URL } from './globals'; +import { LAUNCH_PARAM, PLAYWRIGHT_APP_URL, PLAYWRIGHT_FORMS_SERVER_URL } from './globals'; const questionnaireTitle = 'Aboriginal and Torres Strait Islander Health Check'; @@ -25,7 +25,7 @@ test.beforeEach(async ({ page }) => { const fetchQPromise = page.waitForResponse( `${PLAYWRIGHT_FORMS_SERVER_URL}/Questionnaire?_count=100&_sort=-date&` ); - const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjQxNzMvIiwiYTU3ZDkwZTMtNWY2OS00YjkyLWFhMmUtMjk5MjE4MDg2M2MxIiwiIiwiIiwiIiwiIiwwLDEsIiIsZmFsc2Vd`; + const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM}`; await page.goto(launchUrl); expect((await fetchQPromise).status()).toBe(200); diff --git a/apps/smart-forms-app/e2e/globals.ts b/apps/smart-forms-app/e2e/globals.ts index 27148f3b..79eae977 100644 --- a/apps/smart-forms-app/e2e/globals.ts +++ b/apps/smart-forms-app/e2e/globals.ts @@ -21,3 +21,7 @@ export const PLAYWRIGHT_FORMS_SERVER_URL = 'https://smartforms.csiro.au/api/fhir export const PLAYWRIGHT_APP_URL = process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173'; + +export const LAUNCH_PARAM = process.env.CI + ? 'WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjQxNzMvIiwiYTU3ZDkwZTMtNWY2OS00YjkyLWFhMmUtMjk5MjE4MDg2M2MxIiwiIiwiIiwiIiwiIiwwLDEsIiIsZmFsc2Vd' + : 'WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjUxNzMvIiwiMWZmN2JkYzItMzZiMi00MzAzLThjMDUtYzU3MzQyYzViMDQzIiwiIiwiIiwiIiwiIiwwLDEsIiIsZmFsc2Vd'; diff --git a/apps/smart-forms-app/e2e/saving.spec.ts b/apps/smart-forms-app/e2e/saving.spec.ts index 48b7e5b3..a8049b56 100644 --- a/apps/smart-forms-app/e2e/saving.spec.ts +++ b/apps/smart-forms-app/e2e/saving.spec.ts @@ -16,7 +16,7 @@ */ import { expect, test } from '@playwright/test'; -import { PLAYWRIGHT_APP_URL, PLAYWRIGHT_EHR_URL } from './globals'; +import { LAUNCH_PARAM, PLAYWRIGHT_APP_URL, PLAYWRIGHT_EHR_URL } from './globals'; const questionnaireTitle = 'Aboriginal and Torres Strait Islander Health Check'; @@ -25,7 +25,7 @@ test.beforeEach(async ({ page }) => { const populatePromise = page.waitForResponse( new RegExp(/^https:\/\/proxy\.smartforms\.io\/v\/r4\/fhir\/(Observation|Condition)\?.+$/) ); - const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjQxNzMvIiwiYTU3ZDkwZTMtNWY2OS00YjkyLWFhMmUtMjk5MjE4MDg2M2MxIiwiIiwiIiwiIiwiIiwwLDEsIntcInJvbGVcIjpcInF1ZXN0aW9ubmFpcmUtcmVuZGVyLW9uLWxhdW5jaFwiLFwiY2Fub25pY2FsXCI6XCJodHRwOi8vd3d3LmhlYWx0aC5nb3YuYXUvYXNzZXNzbWVudHMvbWJzLzcxNXwwLjEuMC1hc3NlbWJsZWRcIixcInR5cGVcIjpcIlF1ZXN0aW9ubmFpcmVcIn0iLGZhbHNlXQ`; + const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM}`; await page.goto(launchUrl); const populateResponse = await populatePromise; expect(populateResponse.status()).toBe(200); From 93e658afa054e6c2678a7e00a721632ec2dd3925 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 18:18:20 +0930 Subject: [PATCH 11/18] Remove unused SDC IDE --- .../TableComponents/QuestionnaireListToolbarButtons.tsx | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/smart-forms-app/src/features/dashboard/components/DashboardPages/QuestionnairePage/TableComponents/QuestionnaireListToolbarButtons.tsx b/apps/smart-forms-app/src/features/dashboard/components/DashboardPages/QuestionnairePage/TableComponents/QuestionnaireListToolbarButtons.tsx index 66de10fc..2ffcdfec 100644 --- a/apps/smart-forms-app/src/features/dashboard/components/DashboardPages/QuestionnairePage/TableComponents/QuestionnaireListToolbarButtons.tsx +++ b/apps/smart-forms-app/src/features/dashboard/components/DashboardPages/QuestionnairePage/TableComponents/QuestionnaireListToolbarButtons.tsx @@ -20,8 +20,6 @@ import CreateNewResponseButton from '../Buttons/CreateNewResponseButton.tsx'; import ViewExistingResponsesButton from '../Buttons/ViewExistingResponsesButton.tsx'; import ClearIcon from '@mui/icons-material/Clear'; import useSmartClient from '../../../../../../hooks/useSmartClient.ts'; -import useDebugMode from '../../../../../../hooks/useDebugMode.ts'; -import GoToSdcIdeButton from '../Buttons/GoToSdcIdeButton.tsx'; interface QuestionnaireListToolbarButtonsProps { onClearSelection: () => void; @@ -31,13 +29,9 @@ function QuestionnaireListToolbarButtons(props: QuestionnaireListToolbarButtonsP const { onClearSelection } = props; const { smartClient } = useSmartClient(); - const { debugModeEnabled } = useDebugMode(); - - const isNotLaunched = !smartClient; return ( - {isNotLaunched && debugModeEnabled ? : null} {smartClient ? : null} From f10668c986876496e285ab8f2b16a1995d5ef0e6 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 18:18:29 +0930 Subject: [PATCH 12/18] Update extract package --- push-extract-image.sh | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 push-extract-image.sh diff --git a/push-extract-image.sh b/push-extract-image.sh new file mode 100644 index 00000000..833d0be7 --- /dev/null +++ b/push-extract-image.sh @@ -0,0 +1,26 @@ +#!bash +# +# Copyright 2023 Commonwealth Scientific and Industrial Research +# Organisation (CSIRO) ABN 41 687 119 230. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -xe + +# Compile the Express app. +cd services/extract-express && npm run compile && cd - + +# Build the Docker image for multiple architectures, then push to Docker Hub. +docker buildx build --file ./services/extract-express/Dockerfile --tag aehrc/smart-forms-extract \ + --platform linux/amd64,linux/arm64/v8 --push --no-cache . From 53999d4b3041c788e437f2167913398a763ac4a2 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 19:13:23 +0930 Subject: [PATCH 13/18] Fix e2e tests --- apps/smart-forms-app/e2e/_pre-run.spec.ts | 4 ++-- apps/smart-forms-app/e2e/dashboard.spec.ts | 4 ++-- apps/smart-forms-app/e2e/globals.ts | 6 +++++- apps/smart-forms-app/e2e/saving.spec.ts | 4 ++-- .../RendererNav/RendererNavLaunchQuestionnaireActions.tsx | 1 - 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/smart-forms-app/e2e/_pre-run.spec.ts b/apps/smart-forms-app/e2e/_pre-run.spec.ts index 03265a05..12abe6dc 100644 --- a/apps/smart-forms-app/e2e/_pre-run.spec.ts +++ b/apps/smart-forms-app/e2e/_pre-run.spec.ts @@ -17,7 +17,7 @@ import { expect, test } from '@playwright/test'; import { - LAUNCH_PARAM, + LAUNCH_PARAM_WITHOUT_Q, PLAYWRIGHT_APP_URL, PLAYWRIGHT_EHR_URL, PLAYWRIGHT_FORMS_SERVER_URL @@ -31,7 +31,7 @@ test('launch without questionnaire context, select a questionnaire and create a `${PLAYWRIGHT_FORMS_SERVER_URL}/Questionnaire?_count=100&_sort=-date&` ); - const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM}`; + const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM_WITHOUT_Q}`; await page.goto(launchUrl); expect((await fetchQPromise).status()).toBe(200); diff --git a/apps/smart-forms-app/e2e/dashboard.spec.ts b/apps/smart-forms-app/e2e/dashboard.spec.ts index 01a99177..c509e11b 100644 --- a/apps/smart-forms-app/e2e/dashboard.spec.ts +++ b/apps/smart-forms-app/e2e/dashboard.spec.ts @@ -16,7 +16,7 @@ */ import { expect, test } from '@playwright/test'; -import { LAUNCH_PARAM, PLAYWRIGHT_APP_URL, PLAYWRIGHT_FORMS_SERVER_URL } from './globals'; +import { LAUNCH_PARAM_WITHOUT_Q, PLAYWRIGHT_APP_URL, PLAYWRIGHT_FORMS_SERVER_URL } from './globals'; const questionnaireTitle = 'Aboriginal and Torres Strait Islander Health Check'; @@ -25,7 +25,7 @@ test.beforeEach(async ({ page }) => { const fetchQPromise = page.waitForResponse( `${PLAYWRIGHT_FORMS_SERVER_URL}/Questionnaire?_count=100&_sort=-date&` ); - const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM}`; + const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM_WITHOUT_Q}`; await page.goto(launchUrl); expect((await fetchQPromise).status()).toBe(200); diff --git a/apps/smart-forms-app/e2e/globals.ts b/apps/smart-forms-app/e2e/globals.ts index 79eae977..a3e525ae 100644 --- a/apps/smart-forms-app/e2e/globals.ts +++ b/apps/smart-forms-app/e2e/globals.ts @@ -22,6 +22,10 @@ export const PLAYWRIGHT_APP_URL = process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173'; -export const LAUNCH_PARAM = process.env.CI +export const LAUNCH_PARAM_WITHOUT_Q = process.env.CI ? 'WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjQxNzMvIiwiYTU3ZDkwZTMtNWY2OS00YjkyLWFhMmUtMjk5MjE4MDg2M2MxIiwiIiwiIiwiIiwiIiwwLDEsIiIsZmFsc2Vd' : 'WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjUxNzMvIiwiMWZmN2JkYzItMzZiMi00MzAzLThjMDUtYzU3MzQyYzViMDQzIiwiIiwiIiwiIiwiIiwwLDEsIiIsZmFsc2Vd'; + +export const LAUNCH_PARAM_WITH_Q = process.env.CI + ? 'WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjQxNzMvIiwiYTU3ZDkwZTMtNWY2OS00YjkyLWFhMmUtMjk5MjE4MDg2M2MxIiwiIiwiIiwiIiwiIiwwLDEsIntcInJvbGVcIjpcInF1ZXN0aW9ubmFpcmUtcmVuZGVyLW9uLWxhdW5jaFwiLFwiY2Fub25pY2FsXCI6XCJodHRwOi8vd3d3LmhlYWx0aC5nb3YuYXUvYXNzZXNzbWVudHMvbWJzLzcxNXwwLjEuMC1hc3NlbWJsZWRcIixcInR5cGVcIjpcIlF1ZXN0aW9ubmFpcmVcIn0iLGZhbHNlXQ' + : 'WzAsInBhdC1zZiIsInByaW1hcnktcGV0ZXIiLCJBVVRPIiwwLDAsMCwiZmhpclVzZXIgb25saW5lX2FjY2VzcyBvcGVuaWQgcHJvZmlsZSBwYXRpZW50L0NvbmRpdGlvbi5ycyBwYXRpZW50L09ic2VydmF0aW9uLnJzIGxhdW5jaCBwYXRpZW50L0VuY291bnRlci5ycyBwYXRpZW50L1F1ZXN0aW9ubmFpcmVSZXNwb25zZS5jcnVkcyBwYXRpZW50L1BhdGllbnQucnMiLCJodHRwOi8vbG9jYWxob3N0OjUxNzMvIiwiMWZmN2JkYzItMzZiMi00MzAzLThjMDUtYzU3MzQyYzViMDQzIiwiIiwiIiwiIiwiIiwwLDEsIntcInJvbGVcIjpcInF1ZXN0aW9ubmFpcmUtcmVuZGVyLW9uLWxhdW5jaFwiLFwiY2Fub25pY2FsXCI6XCJodHRwOi8vd3d3LmhlYWx0aC5nb3YuYXUvYXNzZXNzbWVudHMvbWJzLzcxNXwwLjEuMC1hc3NlbWJsZWRcIixcInR5cGVcIjpcIlF1ZXN0aW9ubmFpcmVcIn0iLGZhbHNlXQ'; diff --git a/apps/smart-forms-app/e2e/saving.spec.ts b/apps/smart-forms-app/e2e/saving.spec.ts index a8049b56..abed5913 100644 --- a/apps/smart-forms-app/e2e/saving.spec.ts +++ b/apps/smart-forms-app/e2e/saving.spec.ts @@ -16,7 +16,7 @@ */ import { expect, test } from '@playwright/test'; -import { LAUNCH_PARAM, PLAYWRIGHT_APP_URL, PLAYWRIGHT_EHR_URL } from './globals'; +import { LAUNCH_PARAM_WITH_Q, PLAYWRIGHT_APP_URL, PLAYWRIGHT_EHR_URL } from './globals'; const questionnaireTitle = 'Aboriginal and Torres Strait Islander Health Check'; @@ -25,7 +25,7 @@ test.beforeEach(async ({ page }) => { const populatePromise = page.waitForResponse( new RegExp(/^https:\/\/proxy\.smartforms\.io\/v\/r4\/fhir\/(Observation|Condition)\?.+$/) ); - const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM}`; + const launchUrl = `${PLAYWRIGHT_APP_URL}/launch?iss=https%3A%2F%2Fproxy.smartforms.io%2Fv%2Fr4%2Ffhir&launch=${LAUNCH_PARAM_WITH_Q}`; await page.goto(launchUrl); const populateResponse = await populatePromise; expect(populateResponse.status()).toBe(200); diff --git a/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx b/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx index 8b67cf2a..6b79c699 100644 --- a/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx +++ b/apps/smart-forms-app/src/features/renderer/components/RendererNav/RendererNavLaunchQuestionnaireActions.tsx @@ -66,7 +66,6 @@ function RendererNavLaunchQuestionnaireActions(props: RendererNavLaunchQuestionn - ) : null} From 09ece4198922a1f4a86b36937f9b8e89e30f2f16 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Thu, 20 Jun 2024 19:17:49 +0930 Subject: [PATCH 14/18] Add snackbar on extract success --- .../src/features/playground/components/Playground.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/smart-forms-app/src/features/playground/components/Playground.tsx b/apps/smart-forms-app/src/features/playground/components/Playground.tsx index cce6ec06..e63aa9a8 100644 --- a/apps/smart-forms-app/src/features/playground/components/Playground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/Playground.tsx @@ -158,6 +158,14 @@ function Playground() { }); setExtractedResource(null); } else { + enqueueSnackbar( + 'Extract successful. See advanced properties > extracted to view extracted resource.', + { + preventDuplicate: true, + action: , + autoHideDuration: 8000 + } + ); const extractedResource = await response.json(); setExtractedResource(extractedResource); } From 04a5798fd858159b4b06113ba8ad36aa9f0b73d0 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Fri, 21 Jun 2024 10:21:47 +0930 Subject: [PATCH 15/18] Remove empty QR.item array and tweak initialise QR logic to not generate dummy empty items --- .../smart-forms-renderer/src/utils/initialise.ts | 13 +++---------- .../src/utils/removeEmptyAnswers.ts | 5 ++++- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/smart-forms-renderer/src/utils/initialise.ts b/packages/smart-forms-renderer/src/utils/initialise.ts index b4b6c741..b28e8aef 100644 --- a/packages/smart-forms-renderer/src/utils/initialise.ts +++ b/packages/smart-forms-renderer/src/utils/initialise.ts @@ -58,16 +58,9 @@ export function initialiseQuestionnaireResponse( if (firstTopLevelItem && !questionnaireResponse.item) { const initialItems = readItemInitialValues(questionnaire); - questionnaireResponse.item = - initialItems && initialItems.length > 0 - ? initialItems - : [ - { - linkId: firstTopLevelItem.linkId, - text: firstTopLevelItem.text, - item: [] - } - ]; + if (initialItems && initialItems.length > 0) { + questionnaireResponse.item = initialItems; + } } if (!questionnaireResponse.questionnaire) { diff --git a/packages/smart-forms-renderer/src/utils/removeEmptyAnswers.ts b/packages/smart-forms-renderer/src/utils/removeEmptyAnswers.ts index c3dbd93d..a8a29d2a 100644 --- a/packages/smart-forms-renderer/src/utils/removeEmptyAnswers.ts +++ b/packages/smart-forms-renderer/src/utils/removeEmptyAnswers.ts @@ -23,6 +23,7 @@ import type { } from 'fhir/r4'; import type { EnableWhenExpressions, EnableWhenItems } from '../interfaces/enableWhen.interface'; import { isHiddenByEnableWhen } from './qItem'; +import cloneDeep from 'lodash.clonedeep'; interface removeEmptyAnswersParams { questionnaire: Questionnaire; @@ -54,7 +55,9 @@ export function removeEmptyAnswers(params: removeEmptyAnswersParams): Questionna !topLevelQRItems || topLevelQRItems.length === 0 ) { - return questionnaireResponse; + const updatedQuestionnaireResponse = cloneDeep(questionnaireResponse); + delete updatedQuestionnaireResponse.item; + return updatedQuestionnaireResponse; } topLevelQRItems.forEach((qrItem, i) => { From 75b55a58f35050fc1b22a98c892c60c7611db0e8 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Fri, 21 Jun 2024 10:57:18 +0930 Subject: [PATCH 16/18] Dynamically get target structure map to determine if QR is extractable --- .../src/features/playground/api/extract.ts | 58 +++++++++++++++++++ .../components/ExtractButtonForPlayground.tsx | 12 +++- .../playground/components/JsonEditor.tsx | 18 +----- .../playground/components/Playground.tsx | 47 ++++++++++++--- .../components/PlaygroundRenderer.tsx | 45 ++++++++------ .../components/PrePopButtonForPlayground.tsx | 11 +++- .../components/StoreStateViewer.tsx | 8 +-- .../ExtractedResourceViewer.tsx | 14 ++--- .../features/playground/stores/selector.ts | 17 ++++++ .../playground/stores/smartConfigStore.ts | 44 ++++++++++++++ 10 files changed, 211 insertions(+), 63 deletions(-) create mode 100644 apps/smart-forms-app/src/features/playground/api/extract.ts create mode 100644 apps/smart-forms-app/src/features/playground/stores/selector.ts create mode 100644 apps/smart-forms-app/src/features/playground/stores/smartConfigStore.ts diff --git a/apps/smart-forms-app/src/features/playground/api/extract.ts b/apps/smart-forms-app/src/features/playground/api/extract.ts new file mode 100644 index 00000000..b4c2c2fd --- /dev/null +++ b/apps/smart-forms-app/src/features/playground/api/extract.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { HEADERS } from '../../../api/headers.ts'; +import type { Questionnaire, StructureMap } from 'fhir/r4'; +import * as FHIR from 'fhirclient'; +import { FORMS_SERVER_URL } from '../../../globals.ts'; + +export async function fetchTargetStructureMap( + questionnaire: Questionnaire +): Promise { + let targetStructureMapCanonical = questionnaire.extension?.find( + (extension) => + extension.url === + 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-targetStructureMap' + )?.valueCanonical; + + if (!targetStructureMapCanonical) { + return null; + } + + targetStructureMapCanonical = targetStructureMapCanonical.replace('|', '&version='); + const requestUrl = `/StructureMap?url=${targetStructureMapCanonical}&_sort=_lastUpdated`; + const resource = await FHIR.client(FORMS_SERVER_URL).request({ + url: requestUrl, + headers: HEADERS + }); + + // Response isn't a resource, exit early + if (!resource.resourceType) { + return null; + } + + if (resource.resourceType === 'Bundle') { + return resource.entry?.find((entry: any) => entry.resource?.resourceType === 'StructureMap') + ?.resource as StructureMap; + } + + if (resource.resourceType === 'StructureMap') { + return resource as StructureMap; + } + + return null; +} diff --git a/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx b/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx index e5ef6c41..cdabe9ee 100644 --- a/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx @@ -20,21 +20,27 @@ import React from 'react'; import { CircularProgress, Fade, IconButton, Tooltip } from '@mui/material'; import Typography from '@mui/material/Typography'; import Iconify from '../../../components/Iconify/Iconify.tsx'; +import { FORMS_SERVER_URL } from '../../../globals.ts'; interface ExtractForPlaygroundProps { + extractEnabled: boolean; isExtracting: boolean; onExtract: () => void; } function ExtractButtonForPlayground(props: ExtractForPlaygroundProps) { - const { isExtracting, onExtract } = props; + const { extractEnabled, isExtracting, onExtract } = props; + + const toolTipText = extractEnabled + ? 'Perform $extract' + : `The current questionnaire does not have a target StructureMap for $extract, or the target StructureMap cannot be found on ${FORMS_SERVER_URL}`; return ( <> - + void; buildingState: 'idle' | 'building' | 'built'; - isExtracting: boolean; - extractedResource: any; onBuildForm: (jsonString: string) => unknown; onDestroyForm: () => unknown; } function JsonEditor(props: Props) { - const { - jsonString, - onJsonStringChange, - buildingState, - isExtracting, - extractedResource, - onBuildForm, - onDestroyForm - } = props; + const { jsonString, onJsonStringChange, buildingState, onBuildForm, onDestroyForm } = props; const [view, setView] = useState<'editor' | 'storeState'>('editor'); const [selectedStore, setSelectedStore] = useState('questionnaireResponseStore'); @@ -142,11 +132,7 @@ function JsonEditor(props: Props) { /> ) : ( - + )} diff --git a/apps/smart-forms-app/src/features/playground/components/Playground.tsx b/apps/smart-forms-app/src/features/playground/components/Playground.tsx index e63aa9a8..43af3898 100644 --- a/apps/smart-forms-app/src/features/playground/components/Playground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/Playground.tsx @@ -29,7 +29,13 @@ import PopulationProgressSpinner from '../../../components/Spinners/PopulationPr import { isQuestionnaire } from '../typePredicates/isQuestionnaire.ts'; import type { BuildState } from '../types/buildState.interface.ts'; import { useLocalStorage } from 'usehooks-ts'; -import { buildForm, destroyForm, useQuestionnaireResponseStore } from '@aehrc/smart-forms-renderer'; +import { + buildForm, + destroyForm, + removeEmptyAnswersFromResponse, + useQuestionnaireResponseStore, + useQuestionnaireStore +} from '@aehrc/smart-forms-renderer'; import RendererDebugFooter from '../../renderer/components/RendererDebugFooter/RendererDebugFooter.tsx'; import CloseSnackbar from '../../../components/Snackbar/CloseSnackbar.tsx'; import { TERMINOLOGY_SERVER_URL } from '../../../globals.ts'; @@ -37,6 +43,8 @@ import PlaygroundPicker from './PlaygroundPicker.tsx'; import type { Patient, Practitioner, Questionnaire } from 'fhir/r4'; import PlaygroundHeader from './PlaygroundHeader.tsx'; import { HEADERS } from '../../../api/headers.ts'; +import { fetchTargetStructureMap } from '../api/extract.ts'; +import { useExtractOperationStore } from '../stores/smartConfigStore.ts'; const defaultFhirServerUrl = 'https://hapi.fhir.org/baseR4'; const defaultExtractEndpoint = 'https://proxy.smartforms.io/fhir'; @@ -53,24 +61,36 @@ function Playground() { // $extract-related states const [isExtracting, setExtracting] = useState(false); - const [extractedResource, setExtractedResource] = useState(null); + const sourceQuestionnaire = useQuestionnaireStore.use.sourceQuestionnaire(); const updatableResponse = useQuestionnaireResponseStore.use.updatableResponse(); + const resetExtractOperationStore = useExtractOperationStore.use.resetStore(); + const setTargetStructureMap = useExtractOperationStore.use.setTargetStructureMap(); + const setExtractedResource = useExtractOperationStore.use.setExtractedResource(); + const { enqueueSnackbar } = useSnackbar(); function handleDestroyForm() { setBuildingState('idle'); + resetExtractOperationStore(); destroyForm(); } async function handleBuildQuestionnaireFromString(jsonString: string) { setBuildingState('building'); + resetExtractOperationStore(); + setJsonString(jsonString); try { const parsedQuestionnaire = JSON.parse(jsonString); if (isQuestionnaire(parsedQuestionnaire)) { + const targetStructureMap = await fetchTargetStructureMap(parsedQuestionnaire); + if (targetStructureMap) { + setTargetStructureMap(targetStructureMap); + } + await buildForm(parsedQuestionnaire, undefined, undefined, TERMINOLOGY_SERVER_URL); setBuildingState('built'); } else { @@ -94,13 +114,22 @@ function Playground() { async function handleBuildQuestionnaireFromResource(questionnaire: Questionnaire) { setBuildingState('building'); + resetExtractOperationStore(); + setJsonString(JSON.stringify(questionnaire, null, 2)); + const targetStructureMap = await fetchTargetStructureMap(questionnaire); + if (targetStructureMap) { + setTargetStructureMap(targetStructureMap); + } + await buildForm(questionnaire, undefined, undefined, TERMINOLOGY_SERVER_URL); setBuildingState('built'); } function handleBuildQuestionnaireFromFile(jsonFile: File) { setBuildingState('building'); + resetExtractOperationStore(); + if (!jsonFile.name.endsWith('.json')) { enqueueSnackbar('Attached file must be a JSON file', { variant: 'error', @@ -118,7 +147,13 @@ function Playground() { const jsonString = event.target?.result; if (typeof jsonString === 'string') { setJsonString(jsonString); - await buildForm(JSON.parse(jsonString), undefined, undefined, TERMINOLOGY_SERVER_URL); + const questionnaire = JSON.parse(jsonString); + const targetStructureMap = await fetchTargetStructureMap(questionnaire); + if (targetStructureMap) { + setTargetStructureMap(targetStructureMap); + } + + await buildForm(questionnaire, undefined, undefined, TERMINOLOGY_SERVER_URL); setBuildingState('built'); } else { enqueueSnackbar('There was an issue with the attached JSON file.', { @@ -146,7 +181,7 @@ function Playground() { const response = await fetch(defaultExtractEndpoint + '/QuestionnaireResponse/$extract', { method: 'POST', headers: { ...HEADERS, 'Content-Type': 'application/json;charset=utf-8' }, - body: JSON.stringify(updatableResponse) + body: JSON.stringify(removeEmptyAnswersFromResponse(sourceQuestionnaire, updatableResponse)) }); setExtracting(false); @@ -159,7 +194,7 @@ function Playground() { setExtractedResource(null); } else { enqueueSnackbar( - 'Extract successful. See advanced properties > extracted to view extracted resource.', + 'Extract successful. See Advanced Properties > Extracted to view extracted resource.', { preventDuplicate: true, action: , @@ -213,8 +248,6 @@ function Playground() { jsonString={jsonString} onJsonStringChange={(jsonString: string) => setJsonString(jsonString)} buildingState={buildingState} - isExtracting={isExtracting} - extractedResource={extractedResource} onBuildForm={handleBuildQuestionnaireFromString} onDestroyForm={handleDestroyForm} /> diff --git a/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx b/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx index b1802c68..8c7c35c4 100644 --- a/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx +++ b/apps/smart-forms-app/src/features/playground/components/PlaygroundRenderer.tsx @@ -25,6 +25,7 @@ import { Box, Typography } from '@mui/material'; import useLaunchContextNames from '../hooks/useLaunchContextNames.ts'; import { TERMINOLOGY_SERVER_URL } from '../../../globals.ts'; import ExtractButtonForPlayground from './ExtractButtonForPlayground.tsx'; +import { useExtractOperationStore } from '../stores/smartConfigStore.ts'; interface PlaygroundRendererProps { endpointUrl: string | null; @@ -38,12 +39,14 @@ function PlaygroundRenderer(props: PlaygroundRendererProps) { const { endpointUrl, patient, user, isExtracting, onExtract } = props; const sourceQuestionnaire = useQuestionnaireStore.use.sourceQuestionnaire(); + const targetStructureMap = useExtractOperationStore.use.targetStructureMap(); const [isPopulating, setIsPopulating] = useState(false); const { patientName, userName } = useLaunchContextNames(patient, user); const prePopEnabled = endpointUrl !== null && patient !== null; + const extractEnabled = targetStructureMap !== null; function handlePrepopulate() { if (!prePopEnabled) { @@ -78,24 +81,30 @@ function PlaygroundRenderer(props: PlaygroundRendererProps) { return ( <> - {prePopEnabled ? ( - - - - - - {patientName ? ( - - Patient: {patientName} - - ) : null} - {userName ? ( - - User: {userName} - - ) : null} - - ) : null} + + + + + + {patientName ? ( + + Patient: {patientName} + + ) : null} + {userName ? ( + + User: {userName} + + ) : null} + {isPopulating ? null : } ); diff --git a/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx b/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx index 984aca93..033fc827 100644 --- a/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/PrePopButtonForPlayground.tsx @@ -22,19 +22,24 @@ import CloudDownloadIcon from '@mui/icons-material/CloudDownload'; import Typography from '@mui/material/Typography'; interface PrePopButtonForPlaygroundProps { + prePopEnabled: boolean; isPopulating: boolean; onPopulate: () => void; } function PrePopButtonForPlayground(props: PrePopButtonForPlaygroundProps) { - const { isPopulating, onPopulate } = props; + const { prePopEnabled, isPopulating, onPopulate } = props; + + const toolTipText = prePopEnabled + ? 'Pre-populate form' + : 'Please select a patient in the Launch Context settings (located on the top right) to enable pre-population'; return ( <> - + ; @@ -56,9 +54,7 @@ function StoreStateViewer(props: StoreStateViewerProps) { } if (selectedStore === 'extractedResource') { - return ( - - ); + return ; } return No store selected; diff --git a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx index a90f2a97..eca1e949 100644 --- a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx +++ b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx @@ -1,21 +1,15 @@ import { useState } from 'react'; import GenericStatePropertyPicker from './GenericStatePropertyPicker.tsx'; import GenericViewer from './GenericViewer.tsx'; +import { useExtractOperationStore } from '../../stores/smartConfigStore.ts'; const extractedSectionPropertyNames: string[] = ['extracted']; -interface ExtractedSectionViewerProps { - isExtracting: boolean; - extractedResource: any; -} - -function ExtractedSectionViewer(props: ExtractedSectionViewerProps) { - const { isExtracting, extractedResource } = props; - +function ExtractedSectionViewer() { const [selectedProperty, setSelectedProperty] = useState('extracted'); const [showJsonTree, setShowJsonTree] = useState(false); - const propertyObject = isExtracting ? 'Performing extraction...' : extractedResource; + const extractedResource = useExtractOperationStore.use.extractedResource(); return ( <> @@ -26,7 +20,7 @@ function ExtractedSectionViewer(props: ExtractedSectionViewerProps) { /> diff --git a/apps/smart-forms-app/src/features/playground/stores/selector.ts b/apps/smart-forms-app/src/features/playground/stores/selector.ts new file mode 100644 index 00000000..556ab4e6 --- /dev/null +++ b/apps/smart-forms-app/src/features/playground/stores/selector.ts @@ -0,0 +1,17 @@ +import type { StoreApi } from 'zustand'; +import { useStore } from 'zustand'; + +type WithSelectors = S extends { getState: () => infer T } + ? S & { use: { [K in keyof T]: () => T[K] } } + : never; + +export const createSelectors = >(_store: S) => { + const store = _store as WithSelectors; + store.use = {}; + for (const k of Object.keys(store.getState())) { + // eslint-disable-next-line react-hooks/rules-of-hooks + (store.use as any)[k] = () => useStore(_store, (s) => s[k as keyof typeof s]); + } + + return store; +}; diff --git a/apps/smart-forms-app/src/features/playground/stores/smartConfigStore.ts b/apps/smart-forms-app/src/features/playground/stores/smartConfigStore.ts new file mode 100644 index 00000000..a2b68e4c --- /dev/null +++ b/apps/smart-forms-app/src/features/playground/stores/smartConfigStore.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2024 Commonwealth Scientific and Industrial Research + * Organisation (CSIRO) ABN 41 687 119 230. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createStore } from 'zustand/vanilla'; +import type { StructureMap } from 'fhir/r4'; +import { createSelectors } from './selector'; + +export interface ExtractOperationStoreType { + targetStructureMap: StructureMap | null; + extractedResource: any; + setTargetStructureMap: (structureMap: StructureMap | null) => void; + setExtractedResource: (extractedResource: any) => void; + resetStore: () => void; +} + +export const ExtractOperationStore = createStore()((set) => ({ + targetStructureMap: null, + extractedResource: null, + setTargetStructureMap: (structureMap: StructureMap | null) => + set(() => ({ targetStructureMap: structureMap })), + setExtractedResource: (extractedResource: any) => + set(() => ({ extractedResource: extractedResource })), + resetStore: () => + set(() => ({ + targetStructureMap: null, + extractedResource: null + })) +})); + +export const useExtractOperationStore = createSelectors(ExtractOperationStore); From 7b4a20d45e393f08b3a4a3e965275967606c0a61 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Fri, 21 Jun 2024 11:31:31 +0930 Subject: [PATCH 17/18] Add functionality to write extracted resource back to source FHIR server --- .../src/features/playground/api/extract.ts | 13 +++- .../components/ExtractButtonForPlayground.tsx | 4 +- .../playground/components/JsonEditor.tsx | 12 +++- .../playground/components/Playground.tsx | 1 + .../components/StoreStateViewer.tsx | 5 +- .../ExtractedResourceViewer.tsx | 67 ++++++++++++++++++- .../StoreStateViewers/GenericViewer.tsx | 5 +- 7 files changed, 96 insertions(+), 11 deletions(-) diff --git a/apps/smart-forms-app/src/features/playground/api/extract.ts b/apps/smart-forms-app/src/features/playground/api/extract.ts index b4c2c2fd..6611656f 100644 --- a/apps/smart-forms-app/src/features/playground/api/extract.ts +++ b/apps/smart-forms-app/src/features/playground/api/extract.ts @@ -16,7 +16,7 @@ */ import { HEADERS } from '../../../api/headers.ts'; -import type { Questionnaire, StructureMap } from 'fhir/r4'; +import type { Bundle, Questionnaire, StructureMap } from 'fhir/r4'; import * as FHIR from 'fhirclient'; import { FORMS_SERVER_URL } from '../../../globals.ts'; @@ -56,3 +56,14 @@ export async function fetchTargetStructureMap( return null; } + +export function extractedResourceIsBatchBundle( + extractedResource: any +): extractedResource is Bundle { + return ( + !!extractedResource && + !!extractedResource.resourceType && + extractedResource.resourceType === 'Bundle' && + (extractedResource.type === 'transaction' || extractedResource.type === 'batch') + ); +} diff --git a/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx b/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx index cdabe9ee..3268afa7 100644 --- a/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/ExtractButtonForPlayground.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { CircularProgress, Fade, IconButton, Tooltip } from '@mui/material'; import Typography from '@mui/material/Typography'; -import Iconify from '../../../components/Iconify/Iconify.tsx'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; import { FORMS_SERVER_URL } from '../../../globals.ts'; interface ExtractForPlaygroundProps { @@ -48,7 +48,7 @@ function ExtractButtonForPlayground(props: ExtractForPlaygroundProps) { {isExtracting ? ( ) : ( - + )} diff --git a/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx b/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx index ee5a7206..961038dd 100644 --- a/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx +++ b/apps/smart-forms-app/src/features/playground/components/JsonEditor.tsx @@ -28,12 +28,20 @@ interface Props { jsonString: string; onJsonStringChange: (jsonString: string) => void; buildingState: 'idle' | 'building' | 'built'; + fhirServerUrl: string; onBuildForm: (jsonString: string) => unknown; onDestroyForm: () => unknown; } function JsonEditor(props: Props) { - const { jsonString, onJsonStringChange, buildingState, onBuildForm, onDestroyForm } = props; + const { + jsonString, + onJsonStringChange, + buildingState, + fhirServerUrl, + onBuildForm, + onDestroyForm + } = props; const [view, setView] = useState<'editor' | 'storeState'>('editor'); const [selectedStore, setSelectedStore] = useState('questionnaireResponseStore'); @@ -132,7 +140,7 @@ function JsonEditor(props: Props) { /> ) : ( - + )} diff --git a/apps/smart-forms-app/src/features/playground/components/Playground.tsx b/apps/smart-forms-app/src/features/playground/components/Playground.tsx index 43af3898..307d8b9f 100644 --- a/apps/smart-forms-app/src/features/playground/components/Playground.tsx +++ b/apps/smart-forms-app/src/features/playground/components/Playground.tsx @@ -248,6 +248,7 @@ function Playground() { jsonString={jsonString} onJsonStringChange={(jsonString: string) => setJsonString(jsonString)} buildingState={buildingState} + fhirServerUrl={fhirServerUrl} onBuildForm={handleBuildQuestionnaireFromString} onDestroyForm={handleDestroyForm} /> diff --git a/apps/smart-forms-app/src/features/playground/components/StoreStateViewer.tsx b/apps/smart-forms-app/src/features/playground/components/StoreStateViewer.tsx index ae23eed7..1e1e7874 100644 --- a/apps/smart-forms-app/src/features/playground/components/StoreStateViewer.tsx +++ b/apps/smart-forms-app/src/features/playground/components/StoreStateViewer.tsx @@ -32,10 +32,11 @@ export type StateStore = interface StoreStateViewerProps { selectedStore: StateStore; + fhirServerUrl: string; } function StoreStateViewer(props: StoreStateViewerProps) { - const { selectedStore } = props; + const { selectedStore, fhirServerUrl } = props; if (selectedStore === 'questionnaireStore') { return ; @@ -54,7 +55,7 @@ function StoreStateViewer(props: StoreStateViewerProps) { } if (selectedStore === 'extractedResource') { - return ; + return ; } return No store selected; diff --git a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx index eca1e949..50016bba 100644 --- a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx +++ b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/ExtractedResourceViewer.tsx @@ -2,14 +2,58 @@ import { useState } from 'react'; import GenericStatePropertyPicker from './GenericStatePropertyPicker.tsx'; import GenericViewer from './GenericViewer.tsx'; import { useExtractOperationStore } from '../../stores/smartConfigStore.ts'; +import { Box, Tooltip } from '@mui/material'; +import { LoadingButton } from '@mui/lab'; +import { useSnackbar } from 'notistack'; +import { extractedResourceIsBatchBundle } from '../../api/extract.ts'; +import { HEADERS } from '../../../../api/headers.ts'; +import CloseSnackbar from '../../../../components/Snackbar/CloseSnackbar.tsx'; const extractedSectionPropertyNames: string[] = ['extracted']; -function ExtractedSectionViewer() { +interface ExtractedSectionViewerProps { + fhirServerUrl: string; +} + +function ExtractedSectionViewer(props: ExtractedSectionViewerProps) { + const { fhirServerUrl } = props; const [selectedProperty, setSelectedProperty] = useState('extracted'); const [showJsonTree, setShowJsonTree] = useState(false); + const [writingBack, setWritingBack] = useState(false); const extractedResource = useExtractOperationStore.use.extractedResource(); + const writeBackEnabled = extractedResourceIsBatchBundle(extractedResource); + + const { enqueueSnackbar } = useSnackbar(); + + // Write back extracted resource + async function handleExtract() { + if (!writeBackEnabled) { + return; + } + setWritingBack(true); + + const response = await fetch(fhirServerUrl, { + method: 'POST', + headers: { ...HEADERS, 'Content-Type': 'application/json;charset=utf-8' }, + body: JSON.stringify(extractedResource) + }); + setWritingBack(false); + + if (!response.ok) { + enqueueSnackbar('Failed to write back resource', { + variant: 'error', + preventDuplicate: true, + action: + }); + } else { + enqueueSnackbar(`Write back to ${fhirServerUrl} successful. See Network tab for response`, { + variant: 'success', + preventDuplicate: true, + action: + }); + } + } return ( <> @@ -22,8 +66,25 @@ function ExtractedSectionViewer() { propertyName={selectedProperty} propertyObject={extractedResource} showJsonTree={showJsonTree} - onToggleShowJsonTree={setShowJsonTree} - /> + onToggleShowJsonTree={setShowJsonTree}> + + + + + Write back + + + + + ); } diff --git a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx index d2c22fb5..94d772c2 100644 --- a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx +++ b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx @@ -3,16 +3,18 @@ import NotesIcon from '@mui/icons-material/Notes'; import AccountTreeIcon from '@mui/icons-material/AccountTree'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DebugResponseView from '../../../renderer/components/RendererDebugFooter/DebugResponseView.tsx'; +import { ReactNode } from 'react'; interface GenericViewerProps { propertyName: string; propertyObject: any; showJsonTree: boolean; onToggleShowJsonTree: (toggleShowJsonTree: boolean) => void; + children?: ReactNode; } function GenericViewer(props: GenericViewerProps) { - const { propertyName, propertyObject, showJsonTree, onToggleShowJsonTree } = props; + const { propertyName, propertyObject, showJsonTree, onToggleShowJsonTree, children } = props; if (propertyName === null) { return No property selected; @@ -57,6 +59,7 @@ function GenericViewer(props: GenericViewerProps) { : 'Use text view for fast Ctrl+F debugging.'} + {children} ); From ad43e612a811c23559acda009b47cfd8e019131f Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Fri, 21 Jun 2024 11:48:34 +0930 Subject: [PATCH 18/18] Update package versions and fix linting --- apps/smart-forms-app/package.json | 2 +- .../components/StoreStateViewers/GenericViewer.tsx | 2 +- .../smart-forms-renderer/interfaces/ItemToRepopulate.md | 8 ++++---- .../interfaces/QuestionnaireStoreType.md | 6 ++++++ .../variables/useQuestionnaireStore.md | 8 ++++++++ documentation/docs/sdc/advanced/question.mdx | 2 -- documentation/package.json | 2 +- package-lock.json | 8 ++++---- packages/smart-forms-renderer/package.json | 2 +- services/extract-express/package.json | 2 +- 10 files changed, 27 insertions(+), 15 deletions(-) diff --git a/apps/smart-forms-app/package.json b/apps/smart-forms-app/package.json index a4bbbbca..ab1c92e2 100644 --- a/apps/smart-forms-app/package.json +++ b/apps/smart-forms-app/package.json @@ -28,7 +28,7 @@ "dependencies": { "@aehrc/sdc-assemble": "^1.2.0", "@aehrc/sdc-populate": "^2.2.3", - "@aehrc/smart-forms-renderer": "^0.35.7", + "@aehrc/smart-forms-renderer": "^0.35.8", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/material-icons": "^5.0.18", diff --git a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx index 94d772c2..e4bdb19f 100644 --- a/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx +++ b/apps/smart-forms-app/src/features/playground/components/StoreStateViewers/GenericViewer.tsx @@ -3,7 +3,7 @@ import NotesIcon from '@mui/icons-material/Notes'; import AccountTreeIcon from '@mui/icons-material/AccountTree'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DebugResponseView from '../../../renderer/components/RendererDebugFooter/DebugResponseView.tsx'; -import { ReactNode } from 'react'; +import type { ReactNode } from 'react'; interface GenericViewerProps { propertyName: string; diff --git a/documentation/docs/api/smart-forms-renderer/interfaces/ItemToRepopulate.md b/documentation/docs/api/smart-forms-renderer/interfaces/ItemToRepopulate.md index 3ce65b32..38f528dc 100644 --- a/documentation/docs/api/smart-forms-renderer/interfaces/ItemToRepopulate.md +++ b/documentation/docs/api/smart-forms-renderer/interfaces/ItemToRepopulate.md @@ -12,17 +12,17 @@ The heading of the group to repopulate *** -### newQRItem +### newQRItem? -> **newQRItem**: `QuestionnaireResponseItem` +> `optional` **newQRItem**: `QuestionnaireResponseItem` The new QuestionnaireResponseItem to replace the old one *** -### newQRItems +### newQRItems? -> **newQRItems**: `QuestionnaireResponseItem`[] +> `optional` **newQRItems**: `QuestionnaireResponseItem`[] The new QuestionnaireResponseItems to replace the old ones diff --git a/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md b/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md index b3045d97..ebb5ed89 100644 --- a/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md +++ b/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md @@ -139,6 +139,12 @@ LinkId of the currently focused item *** +### initialExpressions + +> **initialExpressions**: `Record`\<`string`, `InitialExpression`\> + +*** + ### itemTypes > **itemTypes**: `Record`\<`string`, `string`\> diff --git a/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md b/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md index 02ad2f52..ede8e773 100644 --- a/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md +++ b/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md @@ -149,6 +149,14 @@ This is the React version of the store which can be used as React hooks in React `string` +### use.initialExpressions() + +> **initialExpressions**: () => `Record`\<`string`, `InitialExpression`\> + +#### Returns + +`Record`\<`string`, `InitialExpression`\> + ### use.itemTypes() > **itemTypes**: () => `Record`\<`string`, `string`\> diff --git a/documentation/docs/sdc/advanced/question.mdx b/documentation/docs/sdc/advanced/question.mdx index 10005ba0..36d22986 100644 --- a/documentation/docs/sdc/advanced/question.mdx +++ b/documentation/docs/sdc/advanced/question.mdx @@ -214,8 +214,6 @@ SliderStepValue is only supported on the [itemControl](https://hl7.org/fhir/exte Allows the child items of a group to be displayed in a collapsible form where items can be shown and hidden from the view. It is useful for particularly long questionnaires. -Collapsible is only supported on **group** items. - #### Non-Group - Default Open Usage diff --git a/documentation/package.json b/documentation/package.json index ab31e167..c7b66362 100644 --- a/documentation/package.json +++ b/documentation/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc" }, "dependencies": { - "@aehrc/smart-forms-renderer": "^0.35.5", + "@aehrc/smart-forms-renderer": "^0.35.8", "@docusaurus/core": "^3.4.0", "@docusaurus/preset-classic": "^3.4.0", "@docusaurus/theme-live-codeblock": "^3.4.0", diff --git a/package-lock.json b/package-lock.json index 7fc64914..efbe4535 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "dependencies": { "@aehrc/sdc-assemble": "^1.2.0", "@aehrc/sdc-populate": "^2.2.3", - "@aehrc/smart-forms-renderer": "^0.35.7", + "@aehrc/smart-forms-renderer": "^0.35.8", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/material-icons": "^5.0.18", @@ -422,7 +422,7 @@ "name": "@aehrc/smart-forms-documentation", "version": "0.0.0", "dependencies": { - "@aehrc/smart-forms-renderer": "^0.35.5", + "@aehrc/smart-forms-renderer": "^0.35.8", "@docusaurus/core": "^3.4.0", "@docusaurus/preset-classic": "^3.4.0", "@docusaurus/theme-live-codeblock": "^3.4.0", @@ -41652,7 +41652,7 @@ }, "packages/smart-forms-renderer": { "name": "@aehrc/smart-forms-renderer", - "version": "0.35.7", + "version": "0.35.8", "license": "Apache-2.0", "dependencies": { "@aehrc/sdc-populate": "^2.2.3", @@ -42612,7 +42612,7 @@ } }, "services/extract-express": { - "version": "0.1.0", + "version": "0.2.0", "license": "ISC", "dependencies": { "cors": "^2.8.5", diff --git a/packages/smart-forms-renderer/package.json b/packages/smart-forms-renderer/package.json index 66c12be7..44cbc69b 100644 --- a/packages/smart-forms-renderer/package.json +++ b/packages/smart-forms-renderer/package.json @@ -1,6 +1,6 @@ { "name": "@aehrc/smart-forms-renderer", - "version": "0.35.7", + "version": "0.35.8", "description": "FHIR Structured Data Captured (SDC) rendering engine for Smart Forms", "main": "lib/index.js", "scripts": { diff --git a/services/extract-express/package.json b/services/extract-express/package.json index 080c2db9..0bebadb2 100644 --- a/services/extract-express/package.json +++ b/services/extract-express/package.json @@ -1,6 +1,6 @@ { "name": "extract-express", - "version": "0.1.0", + "version": "0.2.0", "description": "", "main": "lib/index.js", "scripts": {