Skip to content
Open
33 changes: 31 additions & 2 deletions src/components/inputs/number/number.component.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<FormFieldInputProps> = ({ field, value, errors, warnings, setFieldValue }) => {
const { t } = useTranslation();
const { layoutType, sessionMode, workspaceLayout } = useFormProviderContext();
Expand Down Expand Up @@ -61,7 +84,13 @@ const NumberField: React.FC<FormFieldInputProps> = ({ field, value, errors, warn
id={field.id}
invalid={errors.length > 0}
invalidText={errors[0]?.message}
label={<FieldLabel field={field} />}
label={<FieldLabel field={field} customLabel={t('fieldLabelWithUnitsAndRange',
'{{fieldDescription}} {{unitsAndRange}}',
{
fieldDescription: t(field.label),
unitsAndRange: extractFieldUnitsAndRange(field.meta?.concept),
interpolation: { escapeValue: false }
})}/>}
max={max}
min={min}
name={field.id}
Expand Down
128 changes: 128 additions & 0 deletions src/components/inputs/number/number.test.tsx
Copy link
Author

Choose a reason for hiding this comment

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

not really sure about jest.mock('react-i18next', () => ({...
it seemed needed for the new i18n label t('{{fieldDescription}} {{unitsAndRange}}'... but I'm not sure if that's correct

Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}));
Expand All @@ -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(<NumberField {...props} />));
};
Expand Down Expand Up @@ -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();
});
});
24 changes: 24 additions & 0 deletions src/components/inputs/unspecified/unspecified.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions src/form-engine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useConcepts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useRestApiMaxResults } from './useRestApiMaxResults';
type ConceptFetchResponse = FetchResponse<{ results: Array<OpenmrsResource> }>;

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<string>): {
concepts: Array<OpenmrsResource> | undefined;
Expand Down
13 changes: 13 additions & 0 deletions src/hooks/useFormFieldsMeta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Loading