From 75b55a58f35050fc1b22a98c892c60c7611db0e8 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Fri, 21 Jun 2024 10:57:18 +0930 Subject: [PATCH] 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 000000000..b4c2c2fd2 --- /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 e5ef6c41a..cdabe9ee8 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 e63aa9a88..43af38980 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 b1802c682..8c7c35c46 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 984aca934..033fc8273 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 a90f2a97c..eca1e9495 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 000000000..556ab4e6f --- /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 000000000..a2b68e4c4 --- /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);