Skip to content

Commit 94aaba8

Browse files
Refactor question type cleaning & added unit tests
2 parents a360ece + 1ed883f commit 94aaba8

File tree

6 files changed

+461
-283
lines changed

6 files changed

+461
-283
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openmrs/esm-form-builder-app",
3-
"version": "3.0.0",
3+
"version": "3.0.1",
44
"license": "MPL-2.0",
55
"description": "Form Builder for O3",
66
"browser": "dist/openmrs-esm-form-builder-app.js",

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +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';
89
import type { FormField, RenderType } from '@openmrs/esm-form-engine-lib';
910
import { questionTypes, renderTypeOptions, renderingTypes } from '@constants';
1011
import styles from './question.scss';
@@ -59,11 +60,14 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
5960
)}
6061
required
6162
/>
63+
6264
<Select
6365
value={formField?.type ?? 'control'}
64-
onChange={(event: React.ChangeEvent<HTMLSelectElement>) =>
65-
setFormField({ ...formField, type: event.target.value })
66-
}
66+
onChange={(event) => {
67+
const newType = event.target.value;
68+
const cleaned = cleanFormFieldForType({ ...formField, type: newType }, newType);
69+
setFormField(cleaned);
70+
}}
6771
id="questionType"
6872
invalidText={t('typeRequired', 'Type is required')}
6973
labelText={t('questionType', 'Question type')}
@@ -148,6 +152,7 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
148152
</RadioButtonGroup>
149153
</>
150154
)}
155+
151156
{formField.type && <QuestionTypeComponent />}
152157
{formField.questionOptions?.rendering && <RenderTypeComponent />}
153158
</>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { cleanFormFieldForType } from '../../question.modal';
2+
import type { FormField } from '@openmrs/esm-form-engine-lib';
3+
4+
describe('cleanFormFieldForType', () => {
5+
it('should remove irrelevant top-level and nested properties when changing the question type', () => {
6+
const formField: FormField = {
7+
id: 'testQuestion',
8+
label: 'Test Question',
9+
type: 'obs',
10+
questionOptions: {
11+
rendering: 'radio',
12+
concept: '12345',
13+
answers: [
14+
{ concept: '111', label: 'Yes' },
15+
{ concept: '222', label: 'No' },
16+
],
17+
irrelevantOption: 'remove me',
18+
},
19+
required: true,
20+
extraProp: 'should be removed',
21+
} as any;
22+
23+
// Change type from 'obs' to 'control'
24+
const newType = 'control';
25+
const cleaned = cleanFormFieldForType(formField, newType);
26+
27+
// Assert that the type is updated.
28+
expect(cleaned.type).toBe('control');
29+
30+
// Assert that required properties remain.
31+
expect(cleaned.id).toBe('testQuestion');
32+
expect(cleaned.label).toBe('Test Question');
33+
34+
// Irrelevant top-level properties should be removed.
35+
expect(cleaned.required).toBeUndefined();
36+
expect(cleaned).not.toHaveProperty('extraProp');
37+
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');
41+
expect(cleaned.questionOptions).not.toHaveProperty('concept');
42+
expect(cleaned.questionOptions).not.toHaveProperty('answers');
43+
expect(cleaned.questionOptions).not.toHaveProperty('irrelevantOption');
44+
});
45+
});

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

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useEffect } from 'react';
1+
import React, { useCallback } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import flattenDeep from 'lodash-es/flattenDeep';
44
import {
@@ -82,18 +82,15 @@ const allowedQuestionOptionsMapping: Record<string, string[]> = {
8282
function cleanQuestionOptionsForType(options: any, newType: string): any {
8383
const allowedOpts = allowedQuestionOptionsMapping[newType] || [];
8484
const cleanedOpts = Object.fromEntries(Object.entries(options).filter(([optKey]) => allowedOpts.includes(optKey)));
85-
// Ensure required property 'rendering' exists if present in the original options.
86-
if (!('rendering' in cleanedOpts) && options.rendering) {
87-
cleanedOpts.rendering = options.rendering;
88-
}
85+
cleanedOpts.rendering = options.rendering;
8986
return cleanedOpts;
9087
}
9188

9289
/**
9390
* Cleans the given form field by retaining only allowed top‑level properties for the new type.
9491
* Also cleans nested questionOptions using the nested mapping.
9592
*/
96-
function cleanFormFieldForType(field: FormField, newType: string): FormField {
93+
export function cleanFormFieldForType(field: FormField, newType: string): FormField {
9794
const allowedKeys = allowedPropertiesMapping[newType] || [];
9895
const cleaned: Partial<FormField> = {};
9996

@@ -109,10 +106,14 @@ function cleanFormFieldForType(field: FormField, newType: string): FormField {
109106
}
110107

111108
cleaned.type = newType;
112-
113109
return cleaned as FormField;
114110
}
115111

112+
const getAllQuestionIds = (questions?: FormField[]): string[] => {
113+
if (!questions) return [];
114+
return flattenDeep(questions.map((question) => [question.id, getAllQuestionIds(question.questions)]));
115+
};
116+
116117
const QuestionModalContent: React.FC<QuestionModalProps> = ({
117118
formField: formFieldProp,
118119
closeModal,
@@ -124,39 +125,41 @@ const QuestionModalContent: React.FC<QuestionModalProps> = ({
124125
}) => {
125126
const { t } = useTranslation();
126127
const { formField, setFormField } = useFormField();
127-
useEffect(() => {
128-
if (formField && formField.type) {
129-
const cleaned = cleanFormFieldForType(formField, formField.type);
130-
if (JSON.stringify(cleaned) !== JSON.stringify(formField)) {
131-
setFormField(cleaned);
132-
}
133-
}
134-
}, [formField?.type, formField, setFormField]);
135-
136-
const getAllQuestionIds = useCallback((questions?: FormField[]): string[] => {
137-
if (!questions) return [];
138-
return flattenDeep(questions.map((question) => [question.id, getAllQuestionIds(question.questions)]));
139-
}, []);
140-
141128
const checkIfQuestionIdExists = useCallback(
142129
(idToTest: string): boolean => {
143130
// Get all IDs from the schema
144131
const schemaIds: string[] =
145132
schema?.pages?.flatMap((page) => page?.sections?.flatMap((section) => getAllQuestionIds(section.questions))) ||
146133
[];
147134

148-
// Get all IDs from the current formField's questions array
135+
// Get all IDs from the obsGroup questions
149136
const formFieldIds: string[] = formField?.questions ? getAllQuestionIds(formField.questions) : [];
150137

138+
// The main question's id
139+
formFieldIds.push(formField.id);
140+
141+
const originalFormFieldQuestionIds =
142+
formFieldProp && formFieldProp.id !== '' ? getAllQuestionIds(formFieldProp.questions) : [];
143+
144+
if (formFieldProp && formFieldProp.id !== '') originalFormFieldQuestionIds.push(formFieldProp.id);
145+
146+
// Remove the ids from the original question from the schema ids
147+
const filteredSchemaIds = schemaIds.slice(); // Create a copy to modify
148+
originalFormFieldQuestionIds.forEach((idToRemove) => {
149+
const indexToRemove = filteredSchemaIds.indexOf(idToRemove);
150+
if (indexToRemove !== -1) {
151+
filteredSchemaIds.splice(indexToRemove, 1);
152+
}
153+
});
154+
151155
// Combine both arrays, along with the parent question ID and count occurrences of the ID
152-
const allIds = [...schemaIds, ...formFieldIds];
153-
if (!formFieldProp || formFieldProp.id !== formField.id) {
154-
allIds.push(formField.id);
155-
}
156+
const allIds = [...filteredSchemaIds, ...formFieldIds];
156157
const occurrences = allIds.filter((id) => id === idToTest).length;
158+
159+
// Return true if ID occurs more than once
157160
return occurrences > 1;
158161
},
159-
[schema, getAllQuestionIds, formField, formFieldProp],
162+
[schema, formField, formFieldProp],
160163
);
161164

162165
const addObsGroupQuestion = useCallback(() => {

0 commit comments

Comments
 (0)