From 9a06c8eb7716c944b2bd371b660e10971e598996 Mon Sep 17 00:00:00 2001 From: Riza Nafis Date: Sun, 28 Jul 2024 22:29:10 +0700 Subject: [PATCH 1/8] Add Quantity Item Form Component --- .../QuantityItem/QuantityField.tsx | 60 +++++ .../QuantityItem/QuantityItem.tsx | 243 ++++++++++++++++++ .../QuantityItem/QuantityUnitField.tsx | 60 +++++ .../SingleItem/SingleItemSwitcher.tsx | 3 +- .../assets/questionnaires/QQuantity.ts | 76 ++++++ .../stories/itemTypes/Quantity.stories.tsx | 8 +- 6 files changed, 448 insertions(+), 2 deletions(-) create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityField.tsx create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityUnitField.tsx diff --git a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityField.tsx b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityField.tsx new file mode 100644 index 000000000..756f70eff --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityField.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import InputAdornment from '@mui/material/InputAdornment'; +import FadingCheckIcon from '../ItemParts/FadingCheckIcon'; +import { StandardTextField } from '../Textfield.styles'; +import type { PropsWithIsTabledAttribute } from '../../../interfaces/renderProps.interface'; + +interface QuantityFieldProps extends PropsWithIsTabledAttribute { + linkId: string; + input: string; + feedback: string; + displayPrompt: string; + displayUnit: string; + entryFormat: string; + readOnly: boolean; + calcExpUpdated: boolean; + onInputChange: (value: string) => void; +} + +function QuantityField(props: QuantityFieldProps) { + const { + linkId, + input, + feedback, + displayPrompt, + displayUnit, + entryFormat, + readOnly, + calcExpUpdated, + isTabled, + onInputChange + } = props; + + return ( + onInputChange(event.target.value)} + disabled={readOnly} + label={displayPrompt} + placeholder={entryFormat === '' ? '0.0' : entryFormat} + fullWidth + isTabled={isTabled} + size="small" + inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }} + InputProps={{ + endAdornment: ( + + + {displayUnit} + + ) + }} + helperText={feedback} + data-test="q-item-quantity-field" + /> + ); +} + +export default QuantityField; diff --git a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx new file mode 100644 index 000000000..cc5e16d4a --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx @@ -0,0 +1,243 @@ +import React, { useCallback, useState } from 'react'; +import type { + PropsWithIsRepeatedAttribute, + PropsWithIsTabledAttribute, + PropsWithParentIsReadOnlyAttribute, + PropsWithQrItemChangeHandler +} from '../../../interfaces/renderProps.interface'; +import type { + Extension, + Quantity, + QuestionnaireItem, + QuestionnaireItemAnswerOption, + QuestionnaireResponseItem +} from 'fhir/r4'; +import useRenderingExtensions from '../../../hooks/useRenderingExtensions'; +import { FullWidthFormComponentBox } from '../../Box.styles'; +import useValidationFeedback from '../../../hooks/useValidationFeedback'; +import debounce from 'lodash.debounce'; +import { DEBOUNCE_DURATION } from '../../../utils/debounce'; +import { createEmptyQrItem } from '../../../utils/qrItem'; +import ItemFieldGrid from '../ItemParts/ItemFieldGrid'; +import { + parseDecimalStringToFloat, + parseDecimalStringWithPrecision +} from '../../../utils/parseInputs'; +import { getDecimalPrecision } from '../../../utils/itemControl'; +import useDecimalCalculatedExpression from '../../../hooks/useDecimalCalculatedExpression'; +import useStringInput from '../../../hooks/useStringInput'; +import useReadOnly from '../../../hooks/useReadOnly'; +import { useQuestionnaireStore } from '../../../stores'; +import Box from '@mui/material/Box'; +import QuantityField from './QuantityField'; +import QuantityUnitField from './QuantityUnitField'; +import Grid from '@mui/material/Grid'; + +interface QuantityItemProps + extends PropsWithQrItemChangeHandler, + PropsWithIsRepeatedAttribute, + PropsWithIsTabledAttribute, + PropsWithParentIsReadOnlyAttribute { + qItem: QuestionnaireItem; + qrItem: QuestionnaireResponseItem | null; +} + +function QuantityItem(props: QuantityItemProps) { + const { qItem, qrItem, isRepeated, isTabled, parentIsReadOnly, onQrItemChange } = props; + + const onFocusLinkId = useQuestionnaireStore.use.onFocusLinkId(); + + const readOnly = useReadOnly(qItem, parentIsReadOnly); + const precision = getDecimalPrecision(qItem); + const { displayUnit, displayPrompt, entryFormat } = useRenderingExtensions(qItem); + + // Init input value + let valueQuantity: Quantity = {}; + let initialInput = ''; + if (qrItem?.answer) { + if (qrItem?.answer[0].valueQuantity) { + valueQuantity = qrItem.answer[0].valueQuantity; + } + + initialInput = + (precision ? valueQuantity.value?.toFixed(precision) : valueQuantity.value?.toString()) || ''; + } + const [input, setInput] = useStringInput(initialInput); + + // Init unit input value + const answerOptions = qItem.extension?.filter( + (f) => f.url === 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption' + ); + const isShowAnswerOptions = answerOptions?.length || false; + const [unitInput, setUnitInput] = useState( + (answerOptions?.at(0) ?? null) as Extension | null + ); + + // Perform validation checks + const feedback = useValidationFeedback(qItem, input); + + // Process calculated expressions + const { calcExpUpdated } = useDecimalCalculatedExpression({ + qItem: qItem, + inputValue: input, + precision: precision, + onChangeByCalcExpressionDecimal: (newValueDecimal: number) => { + setInput( + typeof precision === 'number' + ? newValueDecimal.toFixed(precision) + : newValueDecimal.toString() + ); + onQrItemChange({ + ...createEmptyQrItem(qItem), + answer: [ + { + valueQuantity: { + value: newValueDecimal, + system: unitInput?.valueCoding?.system, + code: unitInput?.valueCoding?.code + } + } + ] + }); + }, + onChangeByCalcExpressionNull: () => { + setInput(''); + onQrItemChange(createEmptyQrItem(qItem)); + } + }); + + // Event handlers + function handleInputChange(newInput: string) { + const parsedNewInput: string = parseDecimalStringWithPrecision(newInput, precision); + + setInput(parsedNewInput); + updateQrItemWithDebounce(parsedNewInput); + } + + function handleUnitInputChange(newInput: QuestionnaireItemAnswerOption | null) { + setUnitInput(newInput); + + if (!input) return; + + onQrItemChange({ + ...createEmptyQrItem(qItem), + answer: precision + ? [ + { + valueQuantity: { + value: parseDecimalStringToFloat(input, precision), + system: newInput?.valueCoding?.system, + code: newInput?.valueCoding?.code + } + } + ] + : [ + { + valueQuantity: { + value: parseFloat(input), + system: newInput?.valueCoding?.system, + code: newInput?.valueCoding?.code + } + } + ] + }); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + const updateQrItemWithDebounce = useCallback( + debounce((parsedNewInput: string) => { + if (parsedNewInput === '') { + onQrItemChange(createEmptyQrItem(qItem)); + } else { + onQrItemChange({ + ...createEmptyQrItem(qItem), + answer: precision + ? [ + { + valueQuantity: { + value: parseDecimalStringToFloat(parsedNewInput, precision), + system: unitInput?.valueCoding?.system, + code: unitInput?.valueCoding?.code + } + } + ] + : [ + { + valueQuantity: { + value: parseFloat(parsedNewInput), + system: unitInput?.valueCoding?.system, + code: unitInput?.valueCoding?.code + } + } + ] + }); + } + }, DEBOUNCE_DURATION), + [onQrItemChange, qItem, displayUnit, precision, unitInput] + ); // Dependencies are tested, debounce is causing eslint to not recognise dependencies + + if (isRepeated) { + return ( + + + {answerOptions?.length ? ( + + ) : null} + + ); + } + + return ( + onFocusLinkId(qItem.linkId)}> + + + + {answerOptions?.length ? ( + + ) : null} + + + + ); +} + +export default QuantityItem; diff --git a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityUnitField.tsx b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityUnitField.tsx new file mode 100644 index 000000000..f9334bca3 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityUnitField.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { getAnswerOptionLabel } from '../../../utils/openChoice'; +import { StandardTextField, TEXT_FIELD_WIDTH } from '../Textfield.styles'; +import Autocomplete from '@mui/material/Autocomplete'; +import type { QuestionnaireItem, QuestionnaireItemAnswerOption } from 'fhir/r4'; +import type { + PropsWithIsTabledAttribute, + PropsWithParentIsReadOnlyAttribute +} from '../../../interfaces/renderProps.interface'; +import useRenderingExtensions from '../../../hooks/useRenderingExtensions'; + +interface QuantityUnitFieldProps + extends PropsWithIsTabledAttribute, + PropsWithParentIsReadOnlyAttribute { + qItem: QuestionnaireItem; + options: QuestionnaireItemAnswerOption[]; + valueSelect: QuestionnaireItemAnswerOption | null; + readOnly: boolean; + onChange: (newValue: QuestionnaireItemAnswerOption | null) => void; +} + +function QuantityUnitField(props: QuantityUnitFieldProps) { + const { qItem, options, valueSelect, readOnly, isTabled, onChange } = props; + + const { displayUnit, displayPrompt, entryFormat } = useRenderingExtensions(qItem); + + return ( + getAnswerOptionLabel(option)} + onChange={(_, newValue) => onChange(newValue as QuestionnaireItemAnswerOption | null)} + freeSolo + autoHighlight + sx={{ maxWidth: !isTabled ? TEXT_FIELD_WIDTH : 3000, minWidth: 160, flexGrow: 1 }} + disabled={readOnly} + size="small" + renderInput={(params) => ( + + {params.InputProps.endAdornment} + {displayUnit} + + ) + }} + /> + )} + /> + ); +} + +export default QuantityUnitField; diff --git a/packages/smart-forms-renderer/src/components/FormComponents/SingleItem/SingleItemSwitcher.tsx b/packages/smart-forms-renderer/src/components/FormComponents/SingleItem/SingleItemSwitcher.tsx index 0af9f8676..6edd53da6 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/SingleItem/SingleItemSwitcher.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/SingleItem/SingleItemSwitcher.tsx @@ -40,6 +40,7 @@ import SliderItem from '../SliderItem/SliderItem'; import IntegerItem from '../IntegerItem/IntegerItem'; import AttachmentItem from '../AttachmentItem/AttachmentItem'; import CustomDateTimeItem from '../DateTimeItems/CustomDateTimeItem/CustomDateTimeItem'; +import QuantityItem from '../QuantityItem/QuantityItem'; interface SingleItemSwitcherProps extends PropsWithQrItemChangeHandler, @@ -219,7 +220,7 @@ function SingleItemSwitcher(props: SingleItemSwitcherProps) { case 'quantity': // FIXME quantity item uses the same component as decimal item currently return ( - Date: Sun, 28 Jul 2024 22:29:26 +0700 Subject: [PATCH 2/8] Update package version --- packages/smart-forms-renderer/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smart-forms-renderer/package.json b/packages/smart-forms-renderer/package.json index 6037993ce..8b597f33e 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.37.1", + "version": "0.38.0", "description": "FHIR Structured Data Captured (SDC) rendering engine for Smart Forms", "main": "lib/index.js", "scripts": { From bf2632f32e34c6433b19e102a3b0b286abce50a3 Mon Sep 17 00:00:00 2001 From: Riza Nafis Date: Sun, 28 Jul 2024 22:57:23 +0700 Subject: [PATCH 3/8] Update packages version --- packages/smart-forms-renderer/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/smart-forms-renderer/package.json b/packages/smart-forms-renderer/package.json index 8b597f33e..4d729f178 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.38.0", + "version": "0.38.1", "description": "FHIR Structured Data Captured (SDC) rendering engine for Smart Forms", "main": "lib/index.js", "scripts": { From 5c3229b4da84118ad5bce2c1a7e5c9335f58986a Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Mon, 12 Aug 2024 11:03:59 +0930 Subject: [PATCH 4/8] Add initialisation/pre-pop of Quantity items --- .../utils/addDisplayToCodings.ts | 7 +- .../utils/parse.ts | 6 +- .../QuantityItem/QuantityComparatorField.tsx | 40 +++++ .../QuantityItem/QuantityItem.tsx | 169 ++++++++++-------- .../QuantityItem/QuantityUnitField.tsx | 42 ++--- .../src/hooks/useStringInput.ts | 1 + .../assets/questionnaires/QPrePopTester.ts | 30 ++++ .../assets/questionnaires/QQuantity.ts | 152 +++++++++++++++- .../stories/itemTypes/Quantity.stories.tsx | 21 ++- .../src/utils/calculatedExpression.ts | 6 +- .../src/utils/quantity.ts | 62 +++++++ 11 files changed, 424 insertions(+), 112 deletions(-) create mode 100644 packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityComparatorField.tsx create mode 100644 packages/smart-forms-renderer/src/utils/quantity.ts diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/addDisplayToCodings.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/addDisplayToCodings.ts index 2fff9846a..5f8beddc2 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/addDisplayToCodings.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/addDisplayToCodings.ts @@ -125,5 +125,10 @@ export async function resolveLookupPromises( } function valueIsCoding(initialExpressionValue: any): initialExpressionValue is Coding { - return initialExpressionValue && initialExpressionValue.system && initialExpressionValue.code; + return ( + initialExpressionValue && + initialExpressionValue.system && + initialExpressionValue.code && + !initialExpressionValue.unit // To exclude valueQuantity objects + ); } diff --git a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/parse.ts b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/parse.ts index 1c040d622..ec29dd1b8 100644 --- a/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/parse.ts +++ b/packages/sdc-populate/src/SDCPopulateQuestionnaireOperation/utils/parse.ts @@ -102,7 +102,11 @@ export function parseValueToAnswer( } } - if (typeof value === 'object') { + if (typeof value === 'object' && value.unit) { + return { valueQuantity: value }; + } + + if (typeof value === 'object' && value.system && value.code) { return { valueCoding: value }; } diff --git a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityComparatorField.tsx b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityComparatorField.tsx new file mode 100644 index 000000000..621112c51 --- /dev/null +++ b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityComparatorField.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import Autocomplete from '@mui/material/Autocomplete'; +import type { PropsWithIsTabledAttribute } from '../../../interfaces/renderProps.interface'; +import MuiTextField from '../TextItem/MuiTextField'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import type { Quantity } from 'fhir/r4'; + +interface QuantityComparatorFieldProps extends PropsWithIsTabledAttribute { + linkId: string; + options: Quantity['comparator'][]; + valueSelect: Quantity['comparator'] | null; + readOnly: boolean; + onChange: (newValue: Quantity['comparator'] | null) => void; +} + +function QuantityComparatorField(props: QuantityComparatorFieldProps) { + const { linkId, options, valueSelect, readOnly, onChange } = props; + + return ( + + onChange(newValue as Quantity['comparator'])} + autoHighlight + sx={{ width: 88 }} + disabled={readOnly} + size="small" + renderInput={(params) => } + /> + + Symbol (optional) + + + ); +} + +export default QuantityComparatorField; diff --git a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx index cc5e16d4a..a82867879 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import type { PropsWithIsRepeatedAttribute, PropsWithIsTabledAttribute, @@ -19,10 +19,7 @@ import debounce from 'lodash.debounce'; import { DEBOUNCE_DURATION } from '../../../utils/debounce'; import { createEmptyQrItem } from '../../../utils/qrItem'; import ItemFieldGrid from '../ItemParts/ItemFieldGrid'; -import { - parseDecimalStringToFloat, - parseDecimalStringWithPrecision -} from '../../../utils/parseInputs'; +import { parseDecimalStringWithPrecision } from '../../../utils/parseInputs'; import { getDecimalPrecision } from '../../../utils/itemControl'; import useDecimalCalculatedExpression from '../../../hooks/useDecimalCalculatedExpression'; import useStringInput from '../../../hooks/useStringInput'; @@ -31,7 +28,12 @@ import { useQuestionnaireStore } from '../../../stores'; import Box from '@mui/material/Box'; import QuantityField from './QuantityField'; import QuantityUnitField from './QuantityUnitField'; -import Grid from '@mui/material/Grid'; +import { + createQuantityItemAnswer, + quantityComparators, + stringIsComparator +} from '../../../utils/quantity'; +import QuantityComparatorField from './QuantityComparatorField'; interface QuantityItemProps extends PropsWithQrItemChangeHandler, @@ -51,38 +53,63 @@ function QuantityItem(props: QuantityItemProps) { const precision = getDecimalPrecision(qItem); const { displayUnit, displayPrompt, entryFormat } = useRenderingExtensions(qItem); - // Init input value + // Get units options if present + const unitOptions = useMemo( + () => + qItem.extension?.filter( + (f) => f.url === 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption' + ) ?? [], + [qItem] + ); + + // Init inputs let valueQuantity: Quantity = {}; - let initialInput = ''; + let initialValueInput = ''; + let initialComparatorInput: Quantity['comparator'] | null = null; + let initialUnitInput: QuestionnaireItemAnswerOption | null = (unitOptions?.at(0) ?? + null) as Extension | null; if (qrItem?.answer) { if (qrItem?.answer[0].valueQuantity) { valueQuantity = qrItem.answer[0].valueQuantity; } - initialInput = + initialValueInput = (precision ? valueQuantity.value?.toFixed(precision) : valueQuantity.value?.toString()) || ''; + + if (valueQuantity.comparator && stringIsComparator(valueQuantity.comparator)) { + initialComparatorInput = valueQuantity.comparator; + } + + if (valueQuantity.code && valueQuantity.system) { + initialUnitInput = { + valueCoding: { + code: valueQuantity.code, + system: valueQuantity.system, + display: valueQuantity.unit + } + }; + } } - const [input, setInput] = useStringInput(initialInput); - // Init unit input value - const answerOptions = qItem.extension?.filter( - (f) => f.url === 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption' + // input states + const [valueInput, setValueInput] = useStringInput(initialValueInput); + const [comparatorInput, setComparatorInput] = useState( + initialComparatorInput ); - const isShowAnswerOptions = answerOptions?.length || false; const [unitInput, setUnitInput] = useState( - (answerOptions?.at(0) ?? null) as Extension | null + initialUnitInput ); // Perform validation checks - const feedback = useValidationFeedback(qItem, input); + const feedback = useValidationFeedback(qItem, valueInput); // Process calculated expressions const { calcExpUpdated } = useDecimalCalculatedExpression({ qItem: qItem, - inputValue: input, + inputValue: valueInput, precision: precision, onChangeByCalcExpressionDecimal: (newValueDecimal: number) => { - setInput( + setValueInput( typeof precision === 'number' ? newValueDecimal.toFixed(precision) : newValueDecimal.toString() @@ -93,6 +120,7 @@ function QuantityItem(props: QuantityItemProps) { { valueQuantity: { value: newValueDecimal, + unit: unitInput?.valueCoding?.display, system: unitInput?.valueCoding?.system, code: unitInput?.valueCoding?.code } @@ -101,48 +129,41 @@ function QuantityItem(props: QuantityItemProps) { }); }, onChangeByCalcExpressionNull: () => { - setInput(''); + setValueInput(''); onQrItemChange(createEmptyQrItem(qItem)); } }); // Event handlers - function handleInputChange(newInput: string) { - const parsedNewInput: string = parseDecimalStringWithPrecision(newInput, precision); + function handleComparatorInputChange(newComparatorInput: Quantity['comparator'] | null) { + setComparatorInput(newComparatorInput); - setInput(parsedNewInput); - updateQrItemWithDebounce(parsedNewInput); + if (!valueInput) return; + + onQrItemChange({ + ...createEmptyQrItem(qItem), + answer: createQuantityItemAnswer(precision, valueInput, newComparatorInput, unitInput) + }); } - function handleUnitInputChange(newInput: QuestionnaireItemAnswerOption | null) { - setUnitInput(newInput); + function handleUnitInputChange(newUnitInput: QuestionnaireItemAnswerOption | null) { + setUnitInput(newUnitInput); - if (!input) return; + if (!valueInput) return; onQrItemChange({ ...createEmptyQrItem(qItem), - answer: precision - ? [ - { - valueQuantity: { - value: parseDecimalStringToFloat(input, precision), - system: newInput?.valueCoding?.system, - code: newInput?.valueCoding?.code - } - } - ] - : [ - { - valueQuantity: { - value: parseFloat(input), - system: newInput?.valueCoding?.system, - code: newInput?.valueCoding?.code - } - } - ] + answer: createQuantityItemAnswer(precision, valueInput, comparatorInput, newUnitInput) }); } + function handleValueInputChange(newInput: string) { + const parsedNewInput: string = parseDecimalStringWithPrecision(newInput, precision); + + setValueInput(parsedNewInput); + updateQrItemWithDebounce(parsedNewInput); + } + // eslint-disable-next-line react-hooks/exhaustive-deps const updateQrItemWithDebounce = useCallback( debounce((parsedNewInput: string) => { @@ -151,37 +172,27 @@ function QuantityItem(props: QuantityItemProps) { } else { onQrItemChange({ ...createEmptyQrItem(qItem), - answer: precision - ? [ - { - valueQuantity: { - value: parseDecimalStringToFloat(parsedNewInput, precision), - system: unitInput?.valueCoding?.system, - code: unitInput?.valueCoding?.code - } - } - ] - : [ - { - valueQuantity: { - value: parseFloat(parsedNewInput), - system: unitInput?.valueCoding?.system, - code: unitInput?.valueCoding?.code - } - } - ] + answer: createQuantityItemAnswer(precision, parsedNewInput, comparatorInput, unitInput) }); } }, DEBOUNCE_DURATION), - [onQrItemChange, qItem, displayUnit, precision, unitInput] + [onQrItemChange, qItem, displayUnit, precision, comparatorInput, unitInput] ); // Dependencies are tested, debounce is causing eslint to not recognise dependencies if (isRepeated) { return ( + - {answerOptions?.length ? ( + {unitOptions.length > 0 ? ( onFocusLinkId(qItem.linkId)}> + - {answerOptions?.length ? ( + {unitOptions.length > 0 ? ( + option.valueCoding?.code === value?.valueCoding?.code + } options={options} getOptionLabel={(option) => getAnswerOptionLabel(option)} onChange={(_, newValue) => onChange(newValue as QuestionnaireItemAnswerOption | null)} - freeSolo autoHighlight sx={{ maxWidth: !isTabled ? TEXT_FIELD_WIDTH : 3000, minWidth: 160, flexGrow: 1 }} disabled={readOnly} size="small" - renderInput={(params) => ( - - {params.InputProps.endAdornment} - {displayUnit} - - ) - }} - /> - )} + renderInput={(params) => } /> ); } diff --git a/packages/smart-forms-renderer/src/hooks/useStringInput.ts b/packages/smart-forms-renderer/src/hooks/useStringInput.ts index b2bb7bc00..6ec4d1bfc 100644 --- a/packages/smart-forms-renderer/src/hooks/useStringInput.ts +++ b/packages/smart-forms-renderer/src/hooks/useStringInput.ts @@ -18,6 +18,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { useEffect, useState } from 'react'; +// The purpose of this hook to sync the string state from external changes i.e. re-population changes etc. function useStringInput(valueFromProps: string): [string, Dispatch>] { const [input, setInput] = useState(valueFromProps); diff --git a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QPrePopTester.ts b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QPrePopTester.ts index 0a8b63789..6558385fd 100644 --- a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QPrePopTester.ts +++ b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QPrePopTester.ts @@ -184,6 +184,14 @@ export const qSelectivePrePopTester: Questionnaire = { } ] }, + { + url: 'http://hl7.org/fhir/StructureDefinition/variable', + valueExpression: { + name: 'ObsBloodPressure', + language: 'application/x-fhir-query', + expression: 'Observation?code=75367002&_count=1&_sort=-date&patient={{%patient.id}}' + } + }, { url: 'http://hl7.org/fhir/StructureDefinition/variable', valueExpression: { @@ -424,6 +432,28 @@ export const qSelectivePrePopTester: Questionnaire = { } ] }, + { + linkId: 'blood-pressure-unit-fixed', + text: 'Blood Pressure', + type: 'quantity', + extension: [ + { + url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-initialExpression', + valueExpression: { + language: 'text/fhirpath', + expression: '%ObsBloodPressure.entry[0].resource.component[0].value' + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 'mm[Hg]', + display: 'mmHg' + } + } + ] + }, { extension: [ { diff --git a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts index 427c3a06a..831be2563 100644 --- a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts +++ b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import type { Questionnaire } from 'fhir/r4'; +import type { Questionnaire, QuestionnaireResponse } from 'fhir/r4'; export const qQuantityBasic: Questionnaire = { resourceType: 'Questionnaire', @@ -39,10 +39,59 @@ export const qQuantityBasic: Questionnaire = { type: 'quantity', repeats: false, text: 'Body Weight' + }, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit', + valueCoding: { system: 'http://unitsofmeasure.org', code: 'kg', display: 'kg' } + } + ], + linkId: 'body-weight-comparator', + type: 'quantity', + repeats: false, + text: 'Body Weight (with comparator symbol)' } ] }; +export const qrQuantityBasicResponse: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [ + { + linkId: 'body-weight', + answer: [ + { + valueQuantity: { + value: 80, + unit: 'kg', + system: 'http://unitsofmeasure.org', + code: 'kg' + } + } + ], + text: 'Body Weight' + }, + { + linkId: 'body-weight-comparator', + answer: [ + { + valueQuantity: { + value: 90, + comparator: '<', + unit: 'kg', + system: 'http://unitsofmeasure.org', + code: 'kg' + } + } + ], + text: 'Body Weight (with comparator symbol)' + } + ], + questionnaire: 'https://smartforms.csiro.au/docs/components/quantity/basic' +}; + export const qQuantityUnitOption: Questionnaire = { resourceType: 'Questionnaire', id: 'QuantityUnitOption', @@ -52,6 +101,7 @@ export const qQuantityUnitOption: Questionnaire = { status: 'draft', publisher: 'AEHRC CSIRO', date: '2024-07-27', + url: 'https://smartforms.csiro.au/docs/components/quantity/unit-option', item: [ { linkId: 'duration', @@ -115,6 +165,106 @@ export const qQuantityUnitOption: Questionnaire = { } } ] + }, + { + linkId: 'duration-comparator', + text: 'Duration (with comparator symbol)', + type: 'quantity', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 'd', + display: 'Day(s)' + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 'wk', + display: 'Week(s)' + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 'mo', + display: 'Month(s)' + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 'a', + display: 'Year(s)' + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 's', + display: 'Second(s)' + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 'min', + display: 'Minute(s)' + } + }, + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unitOption', + valueCoding: { + system: 'http://unitsofmeasure.org', + code: 'hour', + display: 'Hour(s)' + } + } + ] } ] }; + +export const qrQuantityUnitOptionResponse: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [ + { + linkId: 'duration', + answer: [ + { + valueQuantity: { + value: 48, + unit: 'Hour(s)', + system: 'http://unitsofmeasure.org', + code: 'hour' + } + } + ], + text: 'Duration' + }, + { + linkId: 'duration-comparator', + answer: [ + { + valueQuantity: { + value: 48, + comparator: '>=', + unit: 'Hour(s)', + system: 'http://unitsofmeasure.org', + code: 'hour' + } + } + ], + text: 'Duration' + } + ], + questionnaire: 'https://smartforms.csiro.au/docs/components/quantity/unit-option' +}; diff --git a/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx b/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx index 534774ca9..1f6b8ac01 100644 --- a/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx +++ b/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx @@ -17,7 +17,12 @@ import type { Meta, StoryObj } from '@storybook/react'; import BuildFormWrapperForStorybook from '../storybookWrappers/BuildFormWrapperForStorybook'; -import { qQuantityBasic, qQuantityUnitOption } from '../assets/questionnaires'; +import { + qQuantityBasic, + qQuantityUnitOption, + qrQuantityBasicResponse, + qrQuantityUnitOptionResponse +} from '../assets/questionnaires'; // More on how to set up stories at: https://storybook.js.org/docs/react/writing-stories/introduction#default-export const meta = { @@ -38,8 +43,22 @@ export const QuantityBasic: Story = { } }; +export const QuantityBasicResponse: Story = { + args: { + questionnaire: qQuantityBasic, + questionnaireResponse: qrQuantityBasicResponse + } +}; + export const QuantityUnitOption: Story = { args: { questionnaire: qQuantityUnitOption } }; + +export const QuantityUnitOptionResponse: Story = { + args: { + questionnaire: qQuantityUnitOption, + questionnaireResponse: qrQuantityUnitOptionResponse + } +}; diff --git a/packages/smart-forms-renderer/src/utils/calculatedExpression.ts b/packages/smart-forms-renderer/src/utils/calculatedExpression.ts index 2686a6086..f2f1b9ca0 100644 --- a/packages/smart-forms-renderer/src/utils/calculatedExpression.ts +++ b/packages/smart-forms-renderer/src/utils/calculatedExpression.ts @@ -323,7 +323,11 @@ function parseValueToAnswer(qItem: QuestionnaireItem, value: any): Questionnaire } } - if (typeof value === 'object') { + if (typeof value === 'object' && value.unit) { + return { valueQuantity: value }; + } + + if (typeof value === 'object' && value.system && value.code) { return { valueCoding: value }; } diff --git a/packages/smart-forms-renderer/src/utils/quantity.ts b/packages/smart-forms-renderer/src/utils/quantity.ts new file mode 100644 index 000000000..23436d34a --- /dev/null +++ b/packages/smart-forms-renderer/src/utils/quantity.ts @@ -0,0 +1,62 @@ +/* + * 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 type { + Quantity, + QuestionnaireItemAnswerOption, + QuestionnaireResponseItemAnswer +} from 'fhir/r4'; +import { parseDecimalStringToFloat } from './parseInputs'; + +export const quantityComparators: Quantity['comparator'][] = ['<', '<=', '>=', '>']; + +export function stringIsComparator(str: string | undefined): str is Quantity['comparator'] { + return str === '<' || str === '<=' || str === '>=' || str === '>'; +} + +export function createQuantityItemAnswer( + precision: number | null, + parsedNewInput: string, + comparatorInput: Quantity['comparator'] | null, + unitInput: QuestionnaireItemAnswerOption | null +): QuestionnaireResponseItemAnswer[] { + if (precision) { + return [ + { + valueQuantity: { + value: parseDecimalStringToFloat(parsedNewInput, precision), + comparator: comparatorInput ?? undefined, + unit: unitInput?.valueCoding?.display, + system: unitInput?.valueCoding?.system, + code: unitInput?.valueCoding?.code + } + } + ]; + } + + return [ + { + valueQuantity: { + value: parseFloat(parsedNewInput), + comparator: comparatorInput ?? undefined, + unit: unitInput?.valueCoding?.display, + system: unitInput?.valueCoding?.system, + code: unitInput?.valueCoding?.code + } + } + ]; +} From 36cbf1cf0e502b73781bba28bcdf1f022dd575f9 Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Mon, 19 Aug 2024 14:08:25 +1000 Subject: [PATCH 5/8] Add support for calculated expression in Quantity component --- apps/smart-forms-app/package.json | 2 +- package-lock.json | 4 +- .../QuantityItem/QuantityItem.tsx | 36 +++- .../hooks/useDecimalCalculatedExpression.ts | 4 +- .../hooks/useQuantityCalculatedExpression.ts | 177 ++++++++++++++++++ .../src/hooks/useRenderingExtensions.ts | 7 +- .../src/interfaces/valueSet.interface.ts | 19 ++ .../assets/questionnaires/QQuantity.ts | 56 ++++++ .../stories/itemTypes/Quantity.stories.tsx | 7 + .../src/utils/itemControl.ts | 20 +- .../src/utils/valueSet.ts | 33 +++- 11 files changed, 350 insertions(+), 15 deletions(-) create mode 100644 packages/smart-forms-renderer/src/hooks/useQuantityCalculatedExpression.ts diff --git a/apps/smart-forms-app/package.json b/apps/smart-forms-app/package.json index 4e9030ccb..a21798518 100644 --- a/apps/smart-forms-app/package.json +++ b/apps/smart-forms-app/package.json @@ -28,7 +28,7 @@ "dependencies": { "@aehrc/sdc-assemble": "^1.3.1", "@aehrc/sdc-populate": "^2.3.0", - "@aehrc/smart-forms-renderer": "^0.37.1", + "@aehrc/smart-forms-renderer": "^0.38.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/material-icons": "^5.0.18", diff --git a/package-lock.json b/package-lock.json index e6d642b04..efccf9f14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "dependencies": { "@aehrc/sdc-assemble": "^1.3.1", "@aehrc/sdc-populate": "^2.3.0", - "@aehrc/smart-forms-renderer": "^0.37.1", + "@aehrc/smart-forms-renderer": "^0.38.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/material-icons": "^5.0.18", @@ -41527,7 +41527,7 @@ }, "packages/smart-forms-renderer": { "name": "@aehrc/smart-forms-renderer", - "version": "0.37.1", + "version": "0.38.1", "license": "Apache-2.0", "dependencies": { "@aehrc/sdc-populate": "^2.3.0", diff --git a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx index a82867879..a4d0bbcb0 100644 --- a/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx +++ b/packages/smart-forms-renderer/src/components/FormComponents/QuantityItem/QuantityItem.tsx @@ -6,7 +6,6 @@ import type { PropsWithQrItemChangeHandler } from '../../../interfaces/renderProps.interface'; import type { - Extension, Quantity, QuestionnaireItem, QuestionnaireItemAnswerOption, @@ -21,7 +20,6 @@ import { createEmptyQrItem } from '../../../utils/qrItem'; import ItemFieldGrid from '../ItemParts/ItemFieldGrid'; import { parseDecimalStringWithPrecision } from '../../../utils/parseInputs'; import { getDecimalPrecision } from '../../../utils/itemControl'; -import useDecimalCalculatedExpression from '../../../hooks/useDecimalCalculatedExpression'; import useStringInput from '../../../hooks/useStringInput'; import useReadOnly from '../../../hooks/useReadOnly'; import { useQuestionnaireStore } from '../../../stores'; @@ -34,6 +32,7 @@ import { stringIsComparator } from '../../../utils/quantity'; import QuantityComparatorField from './QuantityComparatorField'; +import useQuantityCalculatedExpression from '../../../hooks/useQuantityCalculatedExpression'; interface QuantityItemProps extends PropsWithQrItemChangeHandler, @@ -51,7 +50,7 @@ function QuantityItem(props: QuantityItemProps) { const readOnly = useReadOnly(qItem, parentIsReadOnly); const precision = getDecimalPrecision(qItem); - const { displayUnit, displayPrompt, entryFormat } = useRenderingExtensions(qItem); + const { displayUnit, displayPrompt, entryFormat, quantityUnit } = useRenderingExtensions(qItem); // Get units options if present const unitOptions = useMemo( @@ -66,8 +65,8 @@ function QuantityItem(props: QuantityItemProps) { let valueQuantity: Quantity = {}; let initialValueInput = ''; let initialComparatorInput: Quantity['comparator'] | null = null; - let initialUnitInput: QuestionnaireItemAnswerOption | null = (unitOptions?.at(0) ?? - null) as Extension | null; + let initialUnitInput: QuestionnaireItemAnswerOption | null = + quantityUnit ?? unitOptions?.at(0) ?? null; if (qrItem?.answer) { if (qrItem?.answer[0].valueQuantity) { valueQuantity = qrItem.answer[0].valueQuantity; @@ -104,7 +103,7 @@ function QuantityItem(props: QuantityItemProps) { const feedback = useValidationFeedback(qItem, valueInput); // Process calculated expressions - const { calcExpUpdated } = useDecimalCalculatedExpression({ + const { calcExpUpdated } = useQuantityCalculatedExpression({ qItem: qItem, inputValue: valueInput, precision: precision, @@ -128,6 +127,31 @@ function QuantityItem(props: QuantityItemProps) { ] }); }, + onChangeByCalcExpressionQuantity: ( + newValueDecimal: number, + newUnitSystem, + newUnitCode, + newUnitDisplay + ) => { + setValueInput( + typeof precision === 'number' + ? newValueDecimal.toFixed(precision) + : newValueDecimal.toString() + ); + onQrItemChange({ + ...createEmptyQrItem(qItem), + answer: [ + { + valueQuantity: { + value: newValueDecimal, + unit: newUnitDisplay, + system: newUnitSystem, + code: newUnitCode + } + } + ] + }); + }, onChangeByCalcExpressionNull: () => { setValueInput(''); onQrItemChange(createEmptyQrItem(qItem)); diff --git a/packages/smart-forms-renderer/src/hooks/useDecimalCalculatedExpression.ts b/packages/smart-forms-renderer/src/hooks/useDecimalCalculatedExpression.ts index 10bcd9882..12875552e 100644 --- a/packages/smart-forms-renderer/src/hooks/useDecimalCalculatedExpression.ts +++ b/packages/smart-forms-renderer/src/hooks/useDecimalCalculatedExpression.ts @@ -23,7 +23,7 @@ interface UseDecimalCalculatedExpression { calcExpUpdated: boolean; } -interface useDecimalCalculatedExpressionProps { +interface UseDecimalCalculatedExpressionProps { qItem: QuestionnaireItem; inputValue: string; precision: number | null; @@ -32,7 +32,7 @@ interface useDecimalCalculatedExpressionProps { } function useDecimalCalculatedExpression( - props: useDecimalCalculatedExpressionProps + props: UseDecimalCalculatedExpressionProps ): UseDecimalCalculatedExpression { const { qItem, diff --git a/packages/smart-forms-renderer/src/hooks/useQuantityCalculatedExpression.ts b/packages/smart-forms-renderer/src/hooks/useQuantityCalculatedExpression.ts new file mode 100644 index 000000000..ee5b45c8e --- /dev/null +++ b/packages/smart-forms-renderer/src/hooks/useQuantityCalculatedExpression.ts @@ -0,0 +1,177 @@ +/* + * 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 { useEffect, useState } from 'react'; +import type { QuestionnaireItem } from 'fhir/r4'; +import { useQuestionnaireStore } from '../stores'; +import { validateCodePromise } from '../utils/valueSet'; +import { TERMINOLOGY_SERVER_URL } from '../globals'; +import type { + CodeParameter, + DisplayParameter, + SystemParameter +} from '../interfaces/valueSet.interface'; + +interface UseQuantityCalculatedExpression { + calcExpUpdated: boolean; +} + +interface UseQuantityCalculatedExpressionProps { + qItem: QuestionnaireItem; + inputValue: string; + precision: number | null; + onChangeByCalcExpressionDecimal: (newValue: number) => void; + onChangeByCalcExpressionQuantity: ( + newValue: number, + newUnitSystem: string, + newUnitCode: string, + newUnitDisplay: string + ) => void; + onChangeByCalcExpressionNull: () => void; +} + +function useQuantityCalculatedExpression( + props: UseQuantityCalculatedExpressionProps +): UseQuantityCalculatedExpression { + const { + qItem, + inputValue, + precision, + onChangeByCalcExpressionDecimal, + onChangeByCalcExpressionQuantity, + onChangeByCalcExpressionNull + } = props; + + const calculatedExpressions = useQuestionnaireStore.use.calculatedExpressions(); + + const [calcExpUpdated, setCalcExpUpdated] = useState(false); + + useEffect( + () => { + const calcExpression = calculatedExpressions[qItem.linkId]?.find( + (exp) => exp.from === 'item' + ); + + if (!calcExpression) { + return; + } + + // only update if calculated value is different from current value + if ( + calcExpression.value !== inputValue && + (typeof calcExpression.value === 'number' || + typeof calcExpression.value === 'string' || + calcExpression.value === null) + ) { + // Null path + if (calcExpression.value === null) { + onChangeByCalcExpressionNull(); + return; + } + + // Number path + if (typeof calcExpression.value === 'number') { + const calcExpressionValue = + typeof precision === 'number' + ? parseFloat(calcExpression.value.toFixed(precision)) + : calcExpression.value; + + // only update if calculated value is different from current value + if (calcExpressionValue !== parseFloat(inputValue)) { + // update ui to show calculated value changes + setCalcExpUpdated(true); + setTimeout(() => { + setCalcExpUpdated(false); + }, 500); + + // calculatedExpression value is null + if (calcExpressionValue === null) { + onChangeByCalcExpressionNull(); + return; + } + + // calculatedExpression value is a number + onChangeByCalcExpressionDecimal(calcExpressionValue); + } + } + + // String path (quantity) + if (typeof calcExpression.value === 'string') { + try { + const [value, unitCode] = calcExpression.value.split(' '); + const unitCodeFormatted = unitCode.replace(/'/g, ''); + + const ucumValueSet = 'http://hl7.org/fhir/ValueSet/ucum-units'; + const ucumSystem = 'http://unitsofmeasure.org'; + + validateCodePromise( + ucumValueSet, + ucumSystem, + unitCodeFormatted, + TERMINOLOGY_SERVER_URL + ).then((validateCodeResponse) => { + // Return early if validate-code request fails + if (!validateCodeResponse) { + onChangeByCalcExpressionNull(); + return; + } + + if (validateCodeResponse.parameter) { + const systemParameter = validateCodeResponse.parameter.find( + (p) => p.name === 'system' + ) as SystemParameter; + const codeParameter = validateCodeResponse.parameter.find( + (p) => p.name === 'code' + ) as CodeParameter; + const displayParameter = validateCodeResponse.parameter.find( + (p) => p.name === 'display' + ) as DisplayParameter; + if ( + systemParameter.valueUri && + codeParameter.valueCode && + displayParameter.valueString + ) { + // update ui to show calculated value changes + setCalcExpUpdated(true); + setTimeout(() => { + setCalcExpUpdated(false); + }, 500); + onChangeByCalcExpressionQuantity( + parseFloat(value), + systemParameter.valueUri, + codeParameter.valueCode, + displayParameter.valueString + ); + } + } + }); + } catch (e) { + console.error(e); + onChangeByCalcExpressionNull(); + } + } + } + }, + // Only trigger this effect if calculatedExpression of item changes + // eslint-disable-next-line react-hooks/exhaustive-deps + [calculatedExpressions] + ); + + return { calcExpUpdated: calcExpUpdated }; +} + +export default useQuantityCalculatedExpression; diff --git a/packages/smart-forms-renderer/src/hooks/useRenderingExtensions.ts b/packages/smart-forms-renderer/src/hooks/useRenderingExtensions.ts index dae0eabfd..32df223aa 100644 --- a/packages/smart-forms-renderer/src/hooks/useRenderingExtensions.ts +++ b/packages/smart-forms-renderer/src/hooks/useRenderingExtensions.ts @@ -16,12 +16,13 @@ */ import { + getQuantityUnit, getTextDisplayFlyover, getTextDisplayInstructions, getTextDisplayPrompt, getTextDisplayUnit } from '../utils/itemControl'; -import type { QuestionnaireItem } from 'fhir/r4'; +import type { QuestionnaireItem, QuestionnaireItemAnswerOption } from 'fhir/r4'; import { structuredDataCapture } from 'fhir-sdc-helpers'; import { useMemo } from 'react'; @@ -33,6 +34,7 @@ interface RenderingExtensions { readOnly: boolean; entryFormat: string; required: boolean; + quantityUnit: QuestionnaireItemAnswerOption | null; } function useRenderingExtensions(qItem: QuestionnaireItem): RenderingExtensions { @@ -44,7 +46,8 @@ function useRenderingExtensions(qItem: QuestionnaireItem): RenderingExtensions { displayFlyover: getTextDisplayFlyover(qItem), readOnly: !!qItem.readOnly, entryFormat: structuredDataCapture.getEntryFormat(qItem) ?? '', - required: qItem.required ?? false + required: qItem.required ?? false, + quantityUnit: getQuantityUnit(qItem) }), [qItem] ); diff --git a/packages/smart-forms-renderer/src/interfaces/valueSet.interface.ts b/packages/smart-forms-renderer/src/interfaces/valueSet.interface.ts index 0e9c73532..270e66752 100644 --- a/packages/smart-forms-renderer/src/interfaces/valueSet.interface.ts +++ b/packages/smart-forms-renderer/src/interfaces/valueSet.interface.ts @@ -21,3 +21,22 @@ export interface ValueSetPromise { promise: Promise; valueSet?: ValueSet; } + +export interface ValidateCodeResponse extends Parameters { + parameter: [SystemParameter, CodeParameter, DisplayParameter]; +} + +export interface SystemParameter { + name: 'system'; + valueUri: string; +} + +export interface CodeParameter { + name: 'code'; + valueCode: string; +} + +export interface DisplayParameter { + name: 'display'; + valueString: string; +} diff --git a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts index 831be2563..aa9d6491d 100644 --- a/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts +++ b/packages/smart-forms-renderer/src/stories/assets/questionnaires/QQuantity.ts @@ -268,3 +268,59 @@ export const qrQuantityUnitOptionResponse: QuestionnaireResponse = { ], questionnaire: 'https://smartforms.csiro.au/docs/components/quantity/unit-option' }; + +export const qQuantityCalculation: Questionnaire = { + resourceType: 'Questionnaire', + id: 'QuantityCalculation', + name: 'QuantityCalculation', + title: 'Quantity Calculation', + version: '0.1.0', + status: 'draft', + publisher: 'AEHRC CSIRO', + date: '2024-05-01', + url: 'https://smartforms.csiro.au/docs/components/quantity/calculation', + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/variable', + valueExpression: { + name: 'durationInDays', + language: 'text/fhirpath', + expression: "item.where(linkId='duration-in-days').answer.value" + } + } + ], + item: [ + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit', + valueCoding: { system: 'http://unitsofmeasure.org', code: 'd', display: 'days' } + } + ], + linkId: 'duration-in-days', + type: 'quantity', + repeats: false, + text: 'Duration in Days' + }, + { + extension: [ + { + url: 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit', + valueCoding: { system: 'http://unitsofmeasure.org', code: 'h', display: 'hours' } + }, + { + url: 'http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression', + valueExpression: { + description: 'Duration In Hours', + language: 'text/fhirpath', + expression: '%durationInDays.value * 24' + } + } + ], + linkId: 'duration-in-hours', + type: 'quantity', + repeats: false, + text: 'Duration in Hours' + } + ] +}; diff --git a/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx b/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx index 1f6b8ac01..343c6ddc5 100644 --- a/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx +++ b/packages/smart-forms-renderer/src/stories/itemTypes/Quantity.stories.tsx @@ -19,6 +19,7 @@ import type { Meta, StoryObj } from '@storybook/react'; import BuildFormWrapperForStorybook from '../storybookWrappers/BuildFormWrapperForStorybook'; import { qQuantityBasic, + qQuantityCalculation, qQuantityUnitOption, qrQuantityBasicResponse, qrQuantityUnitOptionResponse @@ -62,3 +63,9 @@ export const QuantityUnitOptionResponse: Story = { questionnaireResponse: qrQuantityUnitOptionResponse } }; + +export const QuantityCalculation: Story = { + args: { + questionnaire: qQuantityCalculation + } +}; diff --git a/packages/smart-forms-renderer/src/utils/itemControl.ts b/packages/smart-forms-renderer/src/utils/itemControl.ts index 7b338373c..180ee8b8a 100644 --- a/packages/smart-forms-renderer/src/utils/itemControl.ts +++ b/packages/smart-forms-renderer/src/utils/itemControl.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import type { Coding, Extension, QuestionnaireItem } from 'fhir/r4'; +import type { Coding, Extension, QuestionnaireItem, QuestionnaireItemAnswerOption } from 'fhir/r4'; import type { RegexValidation } from '../interfaces/regex.interface'; import { structuredDataCapture } from 'fhir-sdc-helpers'; @@ -234,6 +234,24 @@ export function getTextDisplayPrompt(qItem: QuestionnaireItem): string { return ''; } +/** + * Get Quantity unit for items with itemControlCode unit and has a unit childItem + * + * @author Sean Fong + */ +export function getQuantityUnit(qItem: QuestionnaireItem): QuestionnaireItemAnswerOption | null { + // Otherwise, check if the item has a unit extension + const itemControl = qItem.extension?.find( + (extension: Extension) => + extension.url === 'http://hl7.org/fhir/StructureDefinition/questionnaire-unit' + ); + if (itemControl && itemControl.valueCoding) { + return itemControl; + } + + return null; +} + /** * Get decimal text display unit for items with itemControlCode unit and has a unit childItem * diff --git a/packages/smart-forms-renderer/src/utils/valueSet.ts b/packages/smart-forms-renderer/src/utils/valueSet.ts index 06543080b..777860587 100644 --- a/packages/smart-forms-renderer/src/utils/valueSet.ts +++ b/packages/smart-forms-renderer/src/utils/valueSet.ts @@ -29,7 +29,7 @@ import type { import * as FHIR from 'fhirclient'; import type { FhirResourceString } from '../interfaces/populate.interface'; import type { VariableXFhirQuery } from '../interfaces/variables.interface'; -import type { ValueSetPromise } from '../interfaces/valueSet.interface'; +import type { ValidateCodeResponse, ValueSetPromise } from '../interfaces/valueSet.interface'; const VALID_VALUE_SET_URL_REGEX = /https?:\/\/(www\.)?[-\w@:%.+~#=]{2,256}\.[a-z]{2,4}\b([-@\w:%+.~#?&/=]*ValueSet[-@\w:%+.~#?&/=]*)/; @@ -65,6 +65,37 @@ export function getValueSetPromise(url: string, terminologyServerUrl: string): P }); } +function validateCodeResponseIsValid(response: any): response is ValidateCodeResponse { + return ( + response && + response.resourceType === 'Parameters' && + response.parameter && + response.parameter.find((p: any) => p.name === 'code') && + response.parameter.find((p: any) => p.name === 'code').valueCode && + response.parameter.find((p: any) => p.name === 'system') && + response.parameter.find((p: any) => p.name === 'system').valueUri && + response.parameter.find((p: any) => p.name === 'display') && + response.parameter.find((p: any) => p.name === 'display').valueString + ); +} + +export async function validateCodePromise( + url: string, + system: string, + code: string, + terminologyServerUrl: string +): Promise { + const validateCodeResponse = await FHIR.client({ serverUrl: terminologyServerUrl }).request({ + url: `ValueSet/$validate-code?url=${url}&system=${system}&code=${code}` + }); + + if (validateCodeResponse && validateCodeResponseIsValid(validateCodeResponse)) { + return validateCodeResponse; + } + + return null; +} + async function addTimeoutToPromise(promise: Promise, timeoutMs: number) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { From 4cecf14f42df8382855b0fc869d91a01669ea45e Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Mon, 19 Aug 2024 14:16:17 +1000 Subject: [PATCH 6/8] Update package-lock.json --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d956725c9..ef8a39cef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,8 +26,8 @@ "dependencies": { "@aehrc/sdc-assemble": "^1.3.1", "@aehrc/sdc-populate": "^2.3.0", - "@aehrc/smart-forms-renderer": "^0.37.1", - "@emotion/react": "^11.13.0", + "@aehrc/smart-forms-renderer": "^0.38.1", + "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@fontsource/material-icons": "^5.0.18", "@fontsource/roboto": "^5.0.12", @@ -41532,7 +41532,7 @@ }, "packages/smart-forms-renderer": { "name": "@aehrc/smart-forms-renderer", - "version": "0.37.1", + "version": "0.38.1", "license": "Apache-2.0", "dependencies": { "@aehrc/sdc-populate": "^2.3.0", From c7136c3a87b20bd5c47d8d7c988b40965621cd9f Mon Sep 17 00:00:00 2001 From: Sean Fong Date: Mon, 19 Aug 2024 17:07:41 +1000 Subject: [PATCH 7/8] Add documentation on Quantity component --- .../interfaces/QuestionnaireStoreType.md | 52 +++++++++++++++++++ .../variables/useQuestionnaireStore.md | 52 +++++++++++++++++++ documentation/docs/components/quantity.mdx | 36 ++++++++++++- packages/sdc-assemble/README.md | 6 ++- packages/sdc-populate/README.md | 5 +- 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md b/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md index ebb5ed896..37b605b27 100644 --- a/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md +++ b/documentation/docs/api/smart-forms-renderer/interfaces/QuestionnaireStoreType.md @@ -71,6 +71,14 @@ Key-value pair of calculated expressions `Record **currentPageIndex**: `number` + +Index of the current page + +*** + ### currentTabIndex > **currentTabIndex**: `number` @@ -161,6 +169,24 @@ Key-value pair of launch contexts `Record **markPageAsComplete**: (`pageLinkId`) => `void` + +Used to mark a page index as complete + +#### Parameters + +| Parameter | Type | +| :------ | :------ | +| `pageLinkId` | `string` | + +#### Returns + +`void` + +*** + ### markTabAsComplete() > **markTabAsComplete**: (`tabLinkId`) => `void` @@ -217,6 +243,14 @@ Used to set the focused linkId *** +### pages + +> **pages**: `Pages` + +Key-value pair of pages `Record` + +*** + ### populatedContext > **populatedContext**: `Record`\<`string`, `any`\> @@ -293,6 +327,24 @@ FHIR R4 Questionnaire to render *** +### switchPage() + +> **switchPage**: (`newPageIndex`) => `void` + +Used to switch the current page index + +#### Parameters + +| Parameter | Type | +| :------ | :------ | +| `newPageIndex` | `number` | + +#### Returns + +`void` + +*** + ### switchTab() > **switchTab**: (`newTabIndex`) => `void` diff --git a/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md b/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md index ede8e773e..446292e45 100644 --- a/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md +++ b/documentation/docs/api/smart-forms-renderer/variables/useQuestionnaireStore.md @@ -81,6 +81,14 @@ This is the React version of the store which can be used as React hooks in React `Record`\<`string`, `CalculatedExpression`[]\> +### use.currentPageIndex() + +> **currentPageIndex**: () => `number` + +#### Returns + +`number` + ### use.currentTabIndex() > **currentTabIndex**: () => `number` @@ -173,6 +181,24 @@ This is the React version of the store which can be used as React hooks in React `Record`\<`string`, [`LaunchContext`](../interfaces/LaunchContext.md)\> +### use.markPageAsComplete() + +> **markPageAsComplete**: () => (`pageLinkId`) => `void` + +#### Returns + +`Function` + +##### Parameters + +| Parameter | Type | +| :------ | :------ | +| `pageLinkId` | `string` | + +##### Returns + +`void` + ### use.markTabAsComplete() > **markTabAsComplete**: () => (`tabLinkId`) => `void` @@ -229,6 +255,14 @@ This is the React version of the store which can be used as React hooks in React `void` +### use.pages() + +> **pages**: () => `Pages` + +#### Returns + +`Pages` + ### use.populatedContext() > **populatedContext**: () => `Record`\<`string`, `any`\> @@ -305,6 +339,24 @@ This is the React version of the store which can be used as React hooks in React `Questionnaire` +### use.switchPage() + +> **switchPage**: () => (`newPageIndex`) => `void` + +#### Returns + +`Function` + +##### Parameters + +| Parameter | Type | +| :------ | :------ | +| `newPageIndex` | `number` | + +##### Returns + +`void` + ### use.switchTab() > **switchTab**: () => (`newTabIndex`) => `void` diff --git a/documentation/docs/components/quantity.mdx b/documentation/docs/components/quantity.mdx index b4bccad9c..63921a1b4 100644 --- a/documentation/docs/components/quantity.mdx +++ b/documentation/docs/components/quantity.mdx @@ -12,7 +12,7 @@ There is an extension "http://hl7.org/fhir/StructureDefinition/questionnaire-uni :::warning -This component is not thoroughly tested yet. It is currently using the same exact UI component as **[decimal](/docs/components/decimal)**. Please use it with caution. +This component is not thoroughly tested yet. Please use it with caution. ::: @@ -26,7 +26,39 @@ This component is not thoroughly tested yet. It is currently using the same exac width="100%" height="100" /> -{' '} + + +#### With Response + + +