diff --git a/src/components/inputs/number/number.component.tsx b/src/components/inputs/number/number.component.tsx index 40431248a..20272aed2 100644 --- a/src/components/inputs/number/number.component.tsx +++ b/src/components/inputs/number/number.component.tsx @@ -1,7 +1,9 @@ import React, { useCallback, useMemo } from 'react'; -import classNames from 'classnames'; +import isNil from 'lodash/isNil'; import { useTranslation } from 'react-i18next'; import { Layer, NumberInput } from '@carbon/react'; +import { type Concept } from '@openmrs/esm-framework'; +import classNames from 'classnames'; import { isEmpty } from '../../../validators/form-validator'; import { isTrue } from '../../../utils/boolean-utils'; import { type FormFieldInputProps } from '../../../types'; @@ -11,6 +13,27 @@ import FieldLabel from '../../field-label/field-label.component'; import FieldValueView from '../../value/view/field-value-view.component'; import styles from './number.scss'; + +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 { layoutType, sessionMode, workspaceLayout } = useFormProviderContext(); @@ -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={max} min={min} name={field.id} diff --git a/src/components/inputs/number/number.test.tsx b/src/components/inputs/number/number.test.tsx index 25ede1dcf..176f0289d 100644 --- a/src/components/inputs/number/number.test.tsx +++ b/src/components/inputs/number/number.test.tsx @@ -4,6 +4,22 @@ import { act, render, screen } from '@testing-library/react'; import { useFormProviderContext } from '../../../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('../../../provider/form-provider', () => ({ useFormProviderContext: jest.fn(), })); @@ -23,6 +39,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()); }; @@ -106,4 +179,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/components/inputs/unspecified/unspecified.test.tsx b/src/components/inputs/unspecified/unspecified.test.tsx index 67c8df5b2..8e1d8debf 100644 --- a/src/components/inputs/unspecified/unspecified.test.tsx +++ b/src/components/inputs/unspecified/unspecified.test.tsx @@ -56,6 +56,30 @@ jest.mock('../../../hooks/useEncounter', () => ({ }), })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, defaultValueOrOptions, options) => { + if (key === 'fieldLabelWithUnitsAndRange') { + return options.fieldDescription + (options.unitsAndRange ? ' ' + options.unitsAndRange : ''); + } + + 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}`; + } + + if (typeof defaultValueOrOptions === 'string') { + return defaultValueOrOptions; + } + + return key; + } + }), + I18nextProvider: ({ children }) => children +})); + const renderForm = async (mode: SessionMode = 'enter') => { await act(async () => { render( diff --git a/src/form-engine.test.tsx b/src/form-engine.test.tsx index b8ff9f033..aed42afc9 100644 --- a/src/form-engine.test.tsx +++ b/src/form-engine.test.tsx @@ -171,6 +171,30 @@ jest.mock('./hooks/useEncounter', () => ({ }), })); +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key, defaultValueOrOptions, options) => { + if (key === 'fieldLabelWithUnitsAndRange') { + return options.fieldDescription + (options.unitsAndRange ? ' ' + options.unitsAndRange : ''); + } + + 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}`; + } + + if (typeof defaultValueOrOptions === 'string') { + return defaultValueOrOptions; + } + + return key; + } + }), + I18nextProvider: ({ children }) => children +})); + describe('Form engine component', () => { const user = userEvent.setup(); diff --git a/src/hooks/useConcepts.ts b/src/hooks/useConcepts.ts index c8864b91b..e5326f060 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 973b7ae00..0f629e8a5 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 &&