Skip to content

(feat) O3-4201: Enhance Number Question Labels Display Unit and Range (Min/Max) from Concept #454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
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