Skip to content

Commit

Permalink
Add Quantity input (#2)
Browse files Browse the repository at this point in the history
* Add Quantity Item Form Component

* Update package version

* Update packages version
  • Loading branch information
ryuuzake authored Aug 2, 2024
1 parent 0702876 commit af8b8f8
Show file tree
Hide file tree
Showing 7 changed files with 449 additions and 3 deletions.
2 changes: 1 addition & 1 deletion packages/smart-forms-renderer/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<StandardTextField
id={linkId}
value={input}
error={!!feedback}
onChange={(event) => 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: (
<InputAdornment position={'end'}>
<FadingCheckIcon fadeIn={calcExpUpdated} disabled={readOnly} />
{displayUnit}
</InputAdornment>
)
}}
helperText={feedback}
data-test="q-item-quantity-field"
/>
);
}

export default QuantityField;
Original file line number Diff line number Diff line change
@@ -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<QuestionnaireItemAnswerOption | null>(
(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 (
<Box data-test="q-item-quantity-box" display="flex" gap={1}>
<QuantityField
linkId={qItem.linkId}
input={input}
feedback={feedback}
displayPrompt={displayPrompt}
displayUnit={displayUnit}
entryFormat={entryFormat}
readOnly={readOnly}
calcExpUpdated={calcExpUpdated}
isTabled={isTabled}
onInputChange={handleInputChange}
/>
{answerOptions?.length ? (
<QuantityUnitField
qItem={qItem}
options={answerOptions}
valueSelect={unitInput}
readOnly={readOnly}
isTabled={isTabled}
onChange={handleUnitInputChange}
/>
) : null}
</Box>
);
}

return (
<FullWidthFormComponentBox
data-test="q-item-quantity-box"
data-linkid={qItem.linkId}
onClick={() => onFocusLinkId(qItem.linkId)}>
<ItemFieldGrid qItem={qItem} readOnly={readOnly}>
<Box display="flex" gap={1}>
<QuantityField
linkId={qItem.linkId}
input={input}
feedback={feedback}
displayPrompt={displayPrompt}
displayUnit={displayUnit}
entryFormat={entryFormat}
readOnly={readOnly}
calcExpUpdated={calcExpUpdated}
isTabled={isTabled}
onInputChange={handleInputChange}
/>
{answerOptions?.length ? (
<QuantityUnitField
qItem={qItem}
options={answerOptions}
valueSelect={unitInput}
readOnly={readOnly}
isTabled={isTabled}
onChange={handleUnitInputChange}
/>
) : null}
</Box>
</ItemFieldGrid>
</FullWidthFormComponentBox>
);
}

export default QuantityItem;
Original file line number Diff line number Diff line change
@@ -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 (
<Autocomplete
id={qItem.linkId}
value={valueSelect ?? null}
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) => (
<StandardTextField
isTabled={isTabled}
label={displayPrompt}
placeholder={entryFormat}
{...params}
InputProps={{
...params.InputProps,
endAdornment: (
<>
{params.InputProps.endAdornment}
{displayUnit}
</>
)
}}
/>
)}
/>
);
}

export default QuantityUnitField;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -219,7 +220,7 @@ function SingleItemSwitcher(props: SingleItemSwitcherProps) {
case 'quantity':
// FIXME quantity item uses the same component as decimal item currently
return (
<DecimalItem
<QuantityItem
qItem={qItem}
qrItem={qrItem}
isRepeated={isRepeated}
Expand Down
Loading

2 comments on commit af8b8f8

@fongsean
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @ryuuzake, is there any chance we can collaborate on this with a pull request? It looks well made.

Just got a feature request for "Quantity": aehrc#639

@ryuuzake
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure, let me make a pull request to the main repo @fongsean

Please sign in to comment.