Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import RenderTypeComponent from '../rendering-types/rendering-type.component';
import QuestionTypeComponent from '../question-types/question-type.component';
import RequiredLabel from '../common/required-label/required-label.component';
import { useFormField } from '../../form-field-context';
import { cleanFormFieldForType } from '../../question-utils';
import type { FormField, RenderType } from '@openmrs/esm-form-engine-lib';
import { questionTypes, renderTypeOptions, renderingTypes } from '@constants';
import styles from './question.scss';
Expand All @@ -31,6 +32,15 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
return checkIfQuestionIdExists(formField.id);
}, [formField.id, checkIfQuestionIdExists]);

const handleQuestionTypeChange = useCallback(
(event: React.ChangeEvent<HTMLSelectElement>) => {
const newType = event.target.value;
setFormField((prevFormField) => {
return cleanFormFieldForType({ ...prevFormField, type: newType }, newType);
});
},
[setFormField],
);
return (
<>
<TextInput
Expand Down Expand Up @@ -59,11 +69,10 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
)}
required
/>

<Select
value={formField?.type ?? 'control'}
onChange={(event: React.ChangeEvent<HTMLSelectElement>) =>
setFormField({ ...formField, type: event.target.value })
}
onChange={handleQuestionTypeChange}
id="questionType"
invalidText={t('typeRequired', 'Type is required')}
labelText={t('questionType', 'Question type')}
Expand Down Expand Up @@ -148,6 +157,7 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
</RadioButtonGroup>
</>
)}

{formField.type && <QuestionTypeComponent />}
{formField.questionOptions?.rendering && <RenderTypeComponent />}
</>
Expand Down
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since these test cases are explicitly for testing the functions within the question-utils file and doesn't test anything within the question.component itself, it would be better to add these to a question-utils.test file. A test case that you could write for the question.component would be something like loading a formField like:

const formField: FormField = {
      id: 'testQuestion',
      label: 'Test Question',
      type: 'obs',
      questionOptions: {
        rendering: 'radio',
        concept: '12345',
        answers: [
          { concept: '111', label: 'Yes' },
          { concept: '222', label: 'No' },
        ],
      },
      required: true,
    };

and then rendering the question component, and then clicking on the Select Type and changing the type and then checking if setFormField gets called with the unnecessary properties removed

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { cleanFormFieldForType } from '../../question-utils';
import type { FormField } from '@openmrs/esm-form-engine-lib';

describe('cleanFormFieldForType', () => {
it('should remove irrelevant top-level and nested properties when changing the question type', () => {
const formField: FormField = {
id: 'testQuestion',
label: 'Test Question',
type: 'obs',
questionOptions: {
rendering: 'radio',
concept: '12345',
answers: [
{ concept: '111', label: 'Yes' },
{ concept: '222', label: 'No' },
],
},
required: true,
};

// Change type from 'obs' to 'control' and update rendering to 'markdown'
const newType = 'control';
const cleaned = cleanFormFieldForType(
{ ...formField, type: newType, questionOptions: { rendering: 'markdown' } },
newType,
);

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

// Assert that id remains.
expect(cleaned.id).toBe('testQuestion');

// In our design, label is required for all question types, so it should remain.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Actually, the label property is not required for questions that have the renderingType markdown

Suggested change
// In our design, label is required for all question types, so it should remain.

expect(cleaned.label).toBe('Test Question');

// Irrelevant nested properties should be removed.
expect(cleaned.questionOptions).toHaveProperty('rendering', 'markdown');
expect(cleaned.questionOptions).not.toHaveProperty('concept');
expect(cleaned.questionOptions).not.toHaveProperty('answers');
Comment on lines +35 to +40
Copy link
Collaborator

Choose a reason for hiding this comment

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

Instead of asserting property by property, can't we have a result object and assert that the cleaned object is equal to that, i.e:

const expectedObjectAfterClean = {
      id: 'testQuestion',
      type: 'control',
      questionOptions: {
        rendering: 'markdown',
        concept: '12345',
      },
      required: true,
}
expect(cleaned).toEqual(expectedObjectAfterClean);

});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { FormField, RenderType } from '@openmrs/esm-form-engine-lib';
import { allowedPropertiesMapping, allowedQuestionOptionsMapping, allowedRenderingOptionsMapping } from '@constants';

/**
* Cleans the given questionOptions object by retaining only allowed keys for the new type.
* It combines allowed keys from both the question type and the rendering type.
*/
function cleanQuestionOptionsForType(options: any, questionType: string, rendering: RenderType): any {
const allowedByType = allowedQuestionOptionsMapping[questionType] || [];
const allowedByRendering = allowedRenderingOptionsMapping[rendering] || [];
const allowedOpts = new Set(['rendering', ...allowedByType, ...allowedByRendering]);
const cleanedOpts = Object.fromEntries(Object.entries(options).filter(([key]) => allowedOpts.has(key)));
cleanedOpts.rendering = options.rendering;
return cleanedOpts;
}

/**
* Cleans the given form field by retaining only allowed top‑level properties for the new type.
* Also cleans nested questionOptions using the combined mappings for question type and rendering type.
*/
export function cleanFormFieldForType(field: FormField, newType: string): FormField {
const allowedKeys = allowedPropertiesMapping[newType] || [];
const cleaned: Partial<FormField> = {};

allowedKeys.forEach((key) => {
if (key in field) {
(cleaned as any)[key] = field[key as keyof FormField];
}
});

// If questionOptions is allowed and exists, clean it using the nested mapping.
if (cleaned.questionOptions && typeof cleaned.questionOptions === 'object') {
cleaned.questionOptions = cleanQuestionOptionsForType(
cleaned.questionOptions,
newType,
cleaned.questionOptions.rendering as RenderType,
);
}
cleaned.type = newType;
return cleaned as FormField;
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ const QuestionModalContent: React.FC<QuestionModalProps> = ({
const allIds = [...filteredSchemaIds, ...formFieldIds];
const occurrences = allIds.filter((id) => id === idToTest).length;

// Return true if ID occurs more than once
return occurrences > 1;
},
[schema, formField, formFieldProp],
Expand Down Expand Up @@ -166,7 +167,7 @@ const QuestionModalContent: React.FC<QuestionModalProps> = ({
<AccordionItem
key={`Question ${index + 1}`}
title={question.label ?? `Question ${index + 1}`}
open={index === formField.questions?.length - 1}
open={index === formField.questions.length - 1}
className={styles.obsGroupQuestionContent}
>
<FormFieldProvider
Expand Down
80 changes: 79 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RenderType } from '@openmrs/esm-form-engine-lib';
import type { FormField, RenderType } from '@openmrs/esm-form-engine-lib';

export const questionTypes = [
'control',
Expand Down Expand Up @@ -54,3 +54,81 @@ export const renderTypeOptions: Record<Exclude<QuestionType, 'obs'>, Array<Rende
patientIdentifier: ['text'],
programState: ['select'],
};

/**
Required properties for all question types.
*/
export const requiredProperties: Array<keyof FormField> = ['id', 'label', 'type', 'questionOptions'];

/**
Type-specific properties for each question type.
*/
export const typeSpecificProperties: Record<string, Array<keyof FormField>> = {
control: [],
encounterDatetime: ['datePickerFormat'],
encounterLocation: [],
encounterProvider: [],
encounterRole: [],
obs: ['required'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

'required` is a common property

Copy link
Collaborator

Choose a reason for hiding this comment

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

Please see this comment again

obsGroup: ['questions'],
patientIdentifier: [],
testOrder: [],
programState: [],
Copy link
Collaborator

Choose a reason for hiding this comment

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

 programUuid?: string;
 workflowUuid?: string;

};

/**
Merge required properties with type-specific ones.
*/
export const allowedPropertiesMapping: Record<string, string[]> = Object.fromEntries(
Object.entries(typeSpecificProperties).map(([type, props]) => {
const mergedProps = new Set<string>([...requiredProperties, ...props]);
return [type, Array.from(mergedProps)];
}),
);

/**
Mapping of allowed keys for the nested questionOptions object per question type.
*/
export const allowedQuestionOptionsMapping: Record<string, string[]> = {
control: ['rendering'],
encounterDatetime: ['rendering'],
encounterLocation: ['rendering'],
encounterProvider: ['rendering'],
encounterRole: ['rendering'],
obs: ['rendering', 'concept', 'answers'],
obsGroup: ['rendering', 'concept'],
patientIdentifier: ['rendering', 'identifierType', 'minLength', 'maxLength'],
testOrder: ['rendering'],
programState: ['rendering', 'programUuid', 'workflowUuid', 'answers'],
};

/**
Mapping of allowed keys for questionOptions based on the rendering type.
*/
export const allowedRenderingOptionsMapping: Record<RenderType, string[]> = {
checkbox: ['rendering', 'concept', 'answers'],
'checkbox-searchable': ['rendering', 'concept'],
'content-switcher': ['rendering', 'concept'],
date: ['rendering'],
datetime: ['rendering'],
drug: ['rendering', 'concept'],
'encounter-location': ['rendering', 'concept'],
'encounter-provider': ['rendering', 'concept'],
'encounter-role': ['rendering', 'concept', 'isSearchable'],
'fixed-value': ['rendering', 'concept'],
file: ['rendering', 'concept'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

allowedFileTypes?: Array<string>;

group: ['rendering', 'concept'],
number: ['rendering', 'concept', 'min', 'max'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

Also relevant type: step?: number;

problem: ['rendering', 'concept'],
radio: ['rendering', 'concept', 'answers'],
repeating: ['rendering', 'concept'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

Relevant to this: repeatOptions?: RepeatOptions;

select: ['rendering', 'concept', 'answers'],
text: ['rendering', 'concept', 'minLength', 'maxLength'],
textarea: ['rendering', 'concept', 'rows'],
toggle: ['rendering', 'concept', 'toggleOptions'],
'ui-select-extended': ['rendering', 'concept', 'isSearchable'],
'workspace-launcher': ['rendering', 'concept'],
markdown: ['rendering', 'concept'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
markdown: ['rendering', 'concept'],
markdown: ['rendering'],

'extension-widget': ['rendering', 'concept'],
Copy link
Collaborator

Choose a reason for hiding this comment

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

The following are types relevant to this:

 extensionId?: string;
 extensionSlotName?: string;

'select-concept-answers': ['rendering', 'concept', 'answers'],
};
2 changes: 1 addition & 1 deletion translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -232,4 +232,4 @@
"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 ",
"welcomeHeading": "Interactive schema builder",
"yes": "Yes"
}
}