From af8b8f872d0a73cb7cb738c27e8da75ac874c2be Mon Sep 17 00:00:00 2001 From: Riza Nafis <31271087+ryuuzake@users.noreply.github.com> Date: Fri, 2 Aug 2024 21:24:27 +0700 Subject: [PATCH] Add Quantity input (#2) * Add Quantity Item Form Component * Update package version * Update packages version --- packages/smart-forms-renderer/package.json | 2 +- .../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 +- 7 files changed, 449 insertions(+), 3 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/package.json b/packages/smart-forms-renderer/package.json index 05fa88c07..1f988ce78 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.1", "description": "FHIR Structured Data Captured (SDC) rendering engine for Smart Forms", "main": "lib/index.js", "scripts": { 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 ( -