diff --git a/src/components/inputs/number/number.component.tsx b/src/components/inputs/number/number.component.tsx index f33364ca..a056d379 100644 --- a/src/components/inputs/number/number.component.tsx +++ b/src/components/inputs/number/number.component.tsx @@ -1,16 +1,39 @@ import React, { useCallback, useMemo, useState } from 'react'; +import { isNil } from 'lodash'; +import { useTranslation } from 'react-i18next'; import { Layer, NumberInput } from '@carbon/react'; +import { type Concept } from '@openmrs/esm-framework'; import classNames from 'classnames'; import { isTrue } from '../../../utils/boolean-utils'; import { shouldUseInlineLayout } from '../../../utils/form-helper'; import FieldValueView from '../../value/view/field-value-view.component'; import { type FormFieldInputProps } from '../../../types'; import styles from './number.scss'; -import { useTranslation } from 'react-i18next'; import { useFormProviderContext } from '../../../provider/form-provider'; import FieldLabel from '../../field-label/field-label.component'; import { isEmpty } from '../../../validators/form-validator'; + +const extractFieldUnitsAndRange = (concept?: Concept): string => { + if (!concept) { + return ''; + } + + const { hiAbsolute, lowAbsolute, units } = concept; + const displayUnits = units ? ` ${units}` : ''; + const hasLowerLimit = !isNil(lowAbsolute); + const hasUpperLimit = !isNil(hiAbsolute); + + if (hasLowerLimit && hasUpperLimit) { + return `(${lowAbsolute} - ${hiAbsolute}${displayUnits})`; + } else if (hasUpperLimit) { + return `(<= ${hiAbsolute}${displayUnits})`; + } else if (hasLowerLimit) { + return `(>= ${lowAbsolute}${displayUnits})`; + } + return units ? `(${units})` : ''; +}; + const NumberField: React.FC = ({ field, value, errors, warnings, setFieldValue }) => { const { t } = useTranslation(); const [lastBlurredValue, setLastBlurredValue] = useState(value); @@ -61,7 +84,13 @@ const NumberField: React.FC = ({ field, value, errors, warn id={field.id} invalid={errors.length > 0} invalidText={errors[0]?.message} - label={} + label={} max={Number(field.questionOptions.max) || undefined} min={Number(field.questionOptions.min) || undefined} name={field.id} diff --git a/src/components/inputs/number/number.test.tsx b/src/components/inputs/number/number.test.tsx index f1f04dac..9e174e61 100644 --- a/src/components/inputs/number/number.test.tsx +++ b/src/components/inputs/number/number.test.tsx @@ -3,6 +3,22 @@ import { act, render, screen, fireEvent } from '@testing-library/react'; import { useFormProviderContext } from 'src/provider/form-provider'; import NumberField from './number.component'; +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, defaultValueOrOptions, options) => { + + if (typeof defaultValueOrOptions === 'object' && 'fieldDescription' in defaultValueOrOptions) { + return `${defaultValueOrOptions.fieldDescription} ${defaultValueOrOptions.unitsAndRange}`; + } + else if (typeof options === 'object' && 'unitsAndRange' in options) { + return `${options.fieldDescription} ${options.unitsAndRange}`; + } + + return key; + } + }) +})); + jest.mock('src/provider/form-provider', () => ({ useFormProviderContext: jest.fn(), })); @@ -22,6 +38,63 @@ const numberFieldMock = { readonly: false, }; +const numberFieldMockWithUnitsAndRange = { + label: 'Weight', + type: 'obs', + id: 'weight', + questionOptions: { + rendering: 'number', + }, + meta: { + concept: { + units: 'kg', + lowAbsolute: 0, + hiAbsolute: 200, + } + }, + isHidden: false, + isDisabled: false, + readonly: false, +}; + +const numberFieldMockWithUnitsOnly = { + ...numberFieldMockWithUnitsAndRange, + meta: { + concept: { + units: 'kg', + } + }, +}; + +const numberFieldMockWithRangeOnly = { + ...numberFieldMockWithUnitsAndRange, + meta: { + concept: { + lowAbsolute: 0, + hiAbsolute: 200, + } + }, +}; + +const numberFieldMockWithHiAbsoluteOnly = { + ...numberFieldMockWithUnitsAndRange, + meta: { + concept: { + hiAbsolute: 200, + } + }, +}; + +const numberFieldMockWithLowAbsoluteOnly = { + ...numberFieldMockWithUnitsAndRange, + meta: { + concept: { + lowAbsolute: 0, + } + }, +}; + + const renderNumberField = async (props) => { await act(() => render()); }; @@ -104,4 +177,59 @@ describe('NumberField Component', () => { const inputElement = screen.getByLabelText('Weight(kg):') as HTMLInputElement; expect(inputElement).toBeDisabled(); }); + + it('renders units and range', async () => { + await renderNumberField({ + field: numberFieldMockWithUnitsAndRange, + value: '', + errors: [], + warnings: [], + setFieldValue: jest.fn(), + }); + expect(screen.getByLabelText('Weight (0 - 200 kg)')).toBeInTheDocument(); + }); + + it('renders units only', async () => { + await renderNumberField({ + field: numberFieldMockWithUnitsOnly, + value: '', + errors: [], + warnings: [], + setFieldValue: jest.fn(), + }); + expect(screen.getByLabelText('Weight (kg)')).toBeInTheDocument(); + }); + + it('renders range only', async () => { + await renderNumberField({ + field: numberFieldMockWithRangeOnly, + value: '', + errors: [], + warnings: [], + setFieldValue: jest.fn(), + }); + expect(screen.getByLabelText('Weight (0 - 200)')).toBeInTheDocument(); + }); + + it('renders hiAbsolute only', async () => { + await renderNumberField({ + field: numberFieldMockWithHiAbsoluteOnly, + value: '', + errors: [], + warnings: [], + setFieldValue: jest.fn(), + }); + expect(screen.getByLabelText('Weight (<= 200)')).toBeInTheDocument(); + }); + + it('renders lowAbsolute only', async () => { + await renderNumberField({ + field: numberFieldMockWithLowAbsoluteOnly, + value: '', + errors: [], + warnings: [], + setFieldValue: jest.fn(), + }); + expect(screen.getByLabelText('Weight (>= 0)')).toBeInTheDocument(); + }); }); diff --git a/src/hooks/useConcepts.ts b/src/hooks/useConcepts.ts index c8864b91..e5326f06 100644 --- a/src/hooks/useConcepts.ts +++ b/src/hooks/useConcepts.ts @@ -6,7 +6,7 @@ import { useRestApiMaxResults } from './useRestApiMaxResults'; type ConceptFetchResponse = FetchResponse<{ results: Array }>; const conceptRepresentation = - 'custom:(uuid,display,conceptClass:(uuid,display),answers:(uuid,display),conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))'; + 'custom:(units,lowAbsolute,hiAbsolute,uuid,display,conceptClass:(uuid,display),answers:(uuid,display),conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))'; export function useConcepts(references: Set): { concepts: Array | undefined; diff --git a/src/hooks/useFormFieldsMeta.ts b/src/hooks/useFormFieldsMeta.ts index 973b7ae0..0f629e8a 100644 --- a/src/hooks/useFormFieldsMeta.ts +++ b/src/hooks/useFormFieldsMeta.ts @@ -11,6 +11,19 @@ export function useFormFieldsMeta(rawFormFields: FormField[], concepts: OpenmrsR const matchingConcept = findConceptByReference(field.questionOptions.concept, concepts); field.questionOptions.concept = matchingConcept ? matchingConcept.uuid : field.questionOptions.concept; field.label = field.label ? field.label : matchingConcept?.display; + + if (matchingConcept) { + const { lowAbsolute, hiAbsolute } = matchingConcept; + + if (lowAbsolute !== undefined) { + field.questionOptions.min = lowAbsolute; + } + + if (hiAbsolute !== undefined) { + field.questionOptions.max = hiAbsolute; + } + } + if ( codedTypes.includes(field.questionOptions.rendering) && !field.questionOptions.answers?.length &&