Skip to content

Commit 2df050e

Browse files
Use a memoized handler for question type changes
1 parent 94aaba8 commit 2df050e

File tree

6 files changed

+110
-103
lines changed

6 files changed

+110
-103
lines changed

src/components/interactive-builder/modals/question/question-form/question/question.component.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import RenderTypeComponent from '../rendering-types/rendering-type.component';
55
import QuestionTypeComponent from '../question-types/question-type.component';
66
import RequiredLabel from '../common/required-label/required-label.component';
77
import { useFormField } from '../../form-field-context';
8-
import { cleanFormFieldForType } from '../../question.modal';
8+
import { cleanFormFieldForType } from '../../question-utils';
99
import type { FormField, RenderType } from '@openmrs/esm-form-engine-lib';
1010
import { questionTypes, renderTypeOptions, renderingTypes } from '@constants';
1111
import styles from './question.scss';
@@ -32,6 +32,15 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
3232
return checkIfQuestionIdExists(formField.id);
3333
}, [formField.id, checkIfQuestionIdExists]);
3434

35+
const handleQuestionTypeChange = useCallback(
36+
(event: React.ChangeEvent<HTMLSelectElement>) => {
37+
const newType = event.target.value;
38+
setFormField((prevFormField) => {
39+
return cleanFormFieldForType({ ...prevFormField, type: newType }, newType);
40+
});
41+
},
42+
[setFormField],
43+
);
3544
return (
3645
<>
3746
<TextInput
@@ -63,11 +72,7 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
6372

6473
<Select
6574
value={formField?.type ?? 'control'}
66-
onChange={(event) => {
67-
const newType = event.target.value;
68-
const cleaned = cleanFormFieldForType({ ...formField, type: newType }, newType);
69-
setFormField(cleaned);
70-
}}
75+
onChange={handleQuestionTypeChange}
7176
id="questionType"
7277
invalidText={t('typeRequired', 'Type is required')}
7378
labelText={t('questionType', 'Question type')}
Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { cleanFormFieldForType } from '../../question.modal';
1+
import { cleanFormFieldForType } from '../../question-utils';
22
import type { FormField } from '@openmrs/esm-form-engine-lib';
33

44
describe('cleanFormFieldForType', () => {
@@ -14,32 +14,29 @@ describe('cleanFormFieldForType', () => {
1414
{ concept: '111', label: 'Yes' },
1515
{ concept: '222', label: 'No' },
1616
],
17-
irrelevantOption: 'remove me',
1817
},
1918
required: true,
20-
extraProp: 'should be removed',
21-
} as any;
19+
};
2220

23-
// Change type from 'obs' to 'control'
21+
// Change type from 'obs' to 'control' and update rendering to 'markdown'
2422
const newType = 'control';
25-
const cleaned = cleanFormFieldForType(formField, newType);
23+
const cleaned = cleanFormFieldForType(
24+
{ ...formField, type: newType, questionOptions: { rendering: 'markdown' } },
25+
newType,
26+
);
2627

2728
// Assert that the type is updated.
2829
expect(cleaned.type).toBe('control');
2930

30-
// Assert that required properties remain.
31+
// Assert that id remains.
3132
expect(cleaned.id).toBe('testQuestion');
32-
expect(cleaned.label).toBe('Test Question');
3333

34-
// Irrelevant top-level properties should be removed.
35-
expect(cleaned.required).toBeUndefined();
36-
expect(cleaned).not.toHaveProperty('extraProp');
34+
// In our design, label is required for all question types, so it should remain.
35+
expect(cleaned.label).toBe('Test Question');
3736

38-
// For nested questionOptions, only allowed keys remain for 'control'.
39-
// Allowed keys for 'control' in questionOptions are: rendering, minLength, and maxLength.
40-
expect(cleaned.questionOptions).toHaveProperty('rendering', 'radio');
37+
// Irrelevant nested properties should be removed.
38+
expect(cleaned.questionOptions).toHaveProperty('rendering', 'markdown');
4139
expect(cleaned.questionOptions).not.toHaveProperty('concept');
4240
expect(cleaned.questionOptions).not.toHaveProperty('answers');
43-
expect(cleaned.questionOptions).not.toHaveProperty('irrelevantOption');
4441
});
4542
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { FormField } from '@openmrs/esm-form-engine-lib';
2+
import { allowedPropertiesMapping, allowedQuestionOptionsMapping } from '@constants';
3+
4+
/**
5+
* Cleans the given questionOptions object by retaining only allowed keys for the new type.
6+
*/
7+
function cleanQuestionOptionsForType(options: any, newType: string): any {
8+
const allowedOpts = allowedQuestionOptionsMapping[newType] || [];
9+
const cleanedOpts = Object.fromEntries(Object.entries(options).filter(([optKey]) => allowedOpts.includes(optKey)));
10+
cleanedOpts.rendering = options.rendering;
11+
return cleanedOpts;
12+
}
13+
14+
/**
15+
* Cleans the given form field by retaining only allowed top‑level properties for the new type.
16+
* Also cleans nested questionOptions using the nested mapping.
17+
*/
18+
export function cleanFormFieldForType(field: FormField, newType: string): FormField {
19+
const allowedKeys = allowedPropertiesMapping[newType] || [];
20+
const cleaned: Partial<FormField> = {};
21+
22+
allowedKeys.forEach((key) => {
23+
if (key in field) {
24+
(cleaned as any)[key] = field[key as keyof FormField];
25+
}
26+
});
27+
28+
// If questionOptions is allowed and exists, clean it using the nested mapping.
29+
if (cleaned.questionOptions && typeof cleaned.questionOptions === 'object') {
30+
cleaned.questionOptions = cleanQuestionOptionsForType(cleaned.questionOptions, newType);
31+
}
32+
33+
cleaned.type = newType;
34+
return cleaned as FormField;
35+
}

src/components/interactive-builder/modals/question/question.modal.tsx

Lines changed: 3 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -29,86 +29,6 @@ interface QuestionModalProps {
2929
resetIndices: () => void;
3030
}
3131

32-
/**
33-
* Common properties that are required for all question types.
34-
*/
35-
const requiredProperties: Array<keyof FormField> = ['id', 'label', 'type', 'questionOptions'];
36-
37-
/**
38-
* Type-specific properties.
39-
*/
40-
const typeSpecificProperties: Record<string, Array<keyof FormField>> = {
41-
control: [],
42-
encounterDatetime: ['datePickerFormat'],
43-
encounterLocation: [],
44-
encounterProvider: [],
45-
encounterRole: [],
46-
obs: ['required'],
47-
obsGroup: ['questions'],
48-
patientIdentifier: [],
49-
testOrder: [],
50-
programState: [],
51-
};
52-
53-
/**
54-
* Merge required properties with type-specific ones.
55-
*/
56-
const allowedPropertiesMapping: Record<string, string[]> = Object.fromEntries(
57-
Object.entries(typeSpecificProperties).map(([type, props]) => {
58-
const mergedProps = new Set<string>([...requiredProperties, ...props]);
59-
return [type, Array.from(mergedProps)];
60-
}),
61-
);
62-
63-
/**
64-
* Mapping of allowed keys for the nested questionOptions object per question type.
65-
*/
66-
const allowedQuestionOptionsMapping: Record<string, string[]> = {
67-
control: ['rendering', 'minLength', 'maxLength'],
68-
encounterDatetime: ['rendering'],
69-
encounterLocation: ['rendering'],
70-
encounterProvider: ['rendering'],
71-
encounterRole: ['rendering', 'isSearchable'],
72-
obs: ['rendering', 'concept', 'answers'],
73-
obsGroup: ['rendering', 'concept'],
74-
patientIdentifier: ['rendering', 'identifierType', 'minLength', 'maxLength'],
75-
testOrder: ['rendering'],
76-
programState: ['rendering', 'programUuid', 'workflowUuid', 'answers'],
77-
};
78-
79-
/**
80-
* Cleans the given questionOptions object by retaining only allowed keys for the new type.
81-
*/
82-
function cleanQuestionOptionsForType(options: any, newType: string): any {
83-
const allowedOpts = allowedQuestionOptionsMapping[newType] || [];
84-
const cleanedOpts = Object.fromEntries(Object.entries(options).filter(([optKey]) => allowedOpts.includes(optKey)));
85-
cleanedOpts.rendering = options.rendering;
86-
return cleanedOpts;
87-
}
88-
89-
/**
90-
* Cleans the given form field by retaining only allowed top‑level properties for the new type.
91-
* Also cleans nested questionOptions using the nested mapping.
92-
*/
93-
export function cleanFormFieldForType(field: FormField, newType: string): FormField {
94-
const allowedKeys = allowedPropertiesMapping[newType] || [];
95-
const cleaned: Partial<FormField> = {};
96-
97-
allowedKeys.forEach((key) => {
98-
if (key in field) {
99-
(cleaned as any)[key] = field[key as keyof FormField];
100-
}
101-
});
102-
103-
// If questionOptions is allowed and exists, clean it using the nested mapping.
104-
if (cleaned.questionOptions && typeof cleaned.questionOptions === 'object') {
105-
cleaned.questionOptions = cleanQuestionOptionsForType(cleaned.questionOptions, newType);
106-
}
107-
108-
cleaned.type = newType;
109-
return cleaned as FormField;
110-
}
111-
11232
const getAllQuestionIds = (questions?: FormField[]): string[] => {
11333
if (!questions) return [];
11434
return flattenDeep(questions.map((question) => [question.id, getAllQuestionIds(question.questions)]));
@@ -125,6 +45,9 @@ const QuestionModalContent: React.FC<QuestionModalProps> = ({
12545
}) => {
12646
const { t } = useTranslation();
12747
const { formField, setFormField } = useFormField();
48+
/**
49+
* NOTE - this does not support nested obsGroup questions
50+
*/
12851
const checkIfQuestionIdExists = useCallback(
12952
(idToTest: string): boolean => {
13053
// Get all IDs from the schema

src/constants.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RenderType } from '@openmrs/esm-form-engine-lib';
1+
import type { FormField, RenderType } from '@openmrs/esm-form-engine-lib';
22

33
export const questionTypes = [
44
'control',
@@ -54,3 +54,50 @@ export const renderTypeOptions: Record<Exclude<QuestionType, 'obs'>, Array<Rende
5454
patientIdentifier: ['text'],
5555
programState: ['select'],
5656
};
57+
58+
/**
59+
Required properties for all question types.
60+
*/
61+
export const requiredProperties: Array<keyof FormField> = ['id', 'label', 'type', 'questionOptions'];
62+
63+
/**
64+
Type-specific properties for each question type.
65+
*/
66+
export const typeSpecificProperties: Record<string, Array<keyof FormField>> = {
67+
control: [],
68+
encounterDatetime: ['datePickerFormat'],
69+
encounterLocation: [],
70+
encounterProvider: [],
71+
encounterRole: [],
72+
obs: ['required'],
73+
obsGroup: ['questions'],
74+
patientIdentifier: [],
75+
testOrder: [],
76+
programState: [],
77+
};
78+
79+
/**
80+
Merge required properties with type-specific ones.
81+
*/
82+
export const allowedPropertiesMapping: Record<string, string[]> = Object.fromEntries(
83+
Object.entries(typeSpecificProperties).map(([type, props]) => {
84+
const mergedProps = new Set<string>([...requiredProperties, ...props]);
85+
return [type, Array.from(mergedProps)];
86+
}),
87+
);
88+
89+
/**
90+
Mapping of allowed keys for the nested questionOptions object per question type.
91+
*/
92+
export const allowedQuestionOptionsMapping: Record<string, string[]> = {
93+
control: ['rendering', 'minLength', 'maxLength'],
94+
encounterDatetime: ['rendering'],
95+
encounterLocation: ['rendering'],
96+
encounterProvider: ['rendering'],
97+
encounterRole: ['rendering', 'isSearchable'],
98+
obs: ['rendering', 'concept', 'answers'],
99+
obsGroup: ['rendering', 'concept'],
100+
patientIdentifier: ['rendering', 'identifierType', 'minLength', 'maxLength'],
101+
testOrder: ['rendering'],
102+
programState: ['rendering', 'programUuid', 'workflowUuid', 'answers'],
103+
};

translations/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,4 +228,4 @@
228228
"welcomeExplainer": "Add pages, sections and questions to your form. The Preview tab automatically updates as you build your form. For a detailed explanation of what constitutes an OpenMRS form schema, please read through the ",
229229
"welcomeHeading": "Interactive schema builder",
230230
"yes": "Yes"
231-
}
231+
}

0 commit comments

Comments
 (0)