@openmrs/esm-form-engine-lib 2.1.0-pre.1561 → 2.1.0-pre.1565

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-form-engine-lib",
3
- "version": "2.1.0-pre.1561",
3
+ "version": "2.1.0-pre.1565",
4
4
  "description": "React Form Engine for O3",
5
5
  "browser": "dist/openmrs-esm-form-engine-lib.js",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  import { type FormContextProps } from '../provider/form-provider';
2
2
  import { type FormField } from '../types';
3
- import { findObsByFormField, hasPreviousObsValueChanged, ObsAdapter } from './obs-adapter';
3
+ import { hasPreviousObsValueChanged, findObsByFormField, ObsAdapter } from './obs-adapter';
4
4
 
5
5
  const formContext = {
6
6
  methods: null,
@@ -944,280 +944,3 @@ describe('findObsByFormField', () => {
944
944
  expect(matchedObs[0]).toBe(obsList[3]);
945
945
  });
946
946
  });
947
-
948
- describe('ObsAdapter - handling nested obsGroups', () => {
949
- const createNestedFields = (): FormField => ({
950
- label: 'Parent obsGroup',
951
- type: 'obsGroup',
952
- required: false,
953
- id: 'parentObsgroup',
954
- questionOptions: {
955
- rendering: 'group',
956
- concept: '163770AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
957
- },
958
- questions: [
959
- {
960
- label: 'Health Center',
961
- type: 'obs',
962
- required: false,
963
- id: 'healthCenter',
964
- questionOptions: {
965
- rendering: 'select',
966
- concept: '1745AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
967
- answers: [
968
- {
969
- concept: '1560AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
970
- label: 'Family member',
971
- },
972
- {
973
- concept: '1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
974
- label: 'Health clinic/post',
975
- },
976
- {
977
- concept: '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
978
- label: 'Other',
979
- },
980
- ],
981
- },
982
- },
983
- {
984
- label: 'Nested obsGroup',
985
- type: 'obsGroup',
986
- required: false,
987
- id: 'nestedObsgroup',
988
- questionOptions: {
989
- rendering: 'group',
990
- concept: '3f824eeb-8452-4df0-b346-6ed056cbc5b9',
991
- },
992
- questions: [
993
- {
994
- label: 'Comment',
995
- type: 'obs',
996
- required: false,
997
- id: 'comment',
998
- questionOptions: {
999
- rendering: 'textarea',
1000
- concept: '161011AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1001
- },
1002
- },
1003
- {
1004
- label: 'Other Diagnoses',
1005
- type: 'obs',
1006
- required: false,
1007
- id: 'otherDiagnoses',
1008
- questionOptions: {
1009
- rendering: 'select',
1010
- concept: '159947AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1011
- answers: [
1012
- {
1013
- concept: '159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1014
- label: 'Diagnosis certainty',
1015
- },
1016
- ],
1017
- },
1018
- },
1019
- ],
1020
- },
1021
- ],
1022
- });
1023
-
1024
- const createNestedObs = () => ({
1025
- uuid: 'encounter-uuid',
1026
- obs: [
1027
- {
1028
- uuid: 'parent-group-uuid',
1029
- concept: {
1030
- uuid: '163770AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1031
- },
1032
- groupMembers: [
1033
- {
1034
- uuid: 'health-center-uuid',
1035
- concept: {
1036
- uuid: '1745AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1037
- },
1038
- value: {
1039
- uuid: '1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1040
- },
1041
- formFieldPath: 'rfe-forms-healthCenter',
1042
- },
1043
- {
1044
- uuid: 'nested-group-uuid',
1045
- concept: {
1046
- uuid: '3f824eeb-8452-4df0-b346-6ed056cbc5b9',
1047
- },
1048
- groupMembers: [
1049
- {
1050
- uuid: 'comment-uuid',
1051
- concept: {
1052
- uuid: '161011AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1053
- },
1054
- value: 'Test comment for nested group',
1055
- formFieldPath: 'rfe-forms-comment',
1056
- },
1057
- {
1058
- uuid: 'diagnosis-uuid',
1059
- concept: {
1060
- uuid: '159947AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1061
- },
1062
- value: {
1063
- uuid: '159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1064
- },
1065
- formFieldPath: 'rfe-forms-otherDiagnoses',
1066
- },
1067
- ],
1068
- },
1069
- ],
1070
- },
1071
- ],
1072
- });
1073
-
1074
- beforeEach(() => {
1075
- formContext.domainObjectValue = createNestedObs();
1076
- ObsAdapter.tearDown();
1077
- });
1078
-
1079
- it('should get initial values from nested obs groups', async () => {
1080
- const fields = createNestedFields();
1081
-
1082
- const healthCenterField = fields.questions[0];
1083
- const healthCenterValue = await ObsAdapter.getInitialValue(
1084
- healthCenterField,
1085
- formContext.domainObjectValue,
1086
- formContext,
1087
- );
1088
- expect(healthCenterValue).toBe('1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
1089
-
1090
- const commentField = fields.questions[1].questions[0];
1091
- const commentValue = await ObsAdapter.getInitialValue(commentField, formContext.domainObjectValue, formContext);
1092
- expect(commentValue).toBe('Test comment for nested group');
1093
-
1094
- const diagnosisField = fields.questions[1].questions[1];
1095
- const diagnosisValue = await ObsAdapter.getInitialValue(diagnosisField, formContext.domainObjectValue, formContext);
1096
- expect(diagnosisValue).toBe('159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');
1097
- });
1098
-
1099
- it('should transform values in nested groups', () => {
1100
- const fields = createNestedFields();
1101
-
1102
- const healthCenterField = fields.questions[0];
1103
- const healthCenterObs = ObsAdapter.transformFieldValue(
1104
- healthCenterField,
1105
- '1560AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1106
- formContext,
1107
- );
1108
- expect(healthCenterObs).toEqual({
1109
- concept: '1745AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1110
- formFieldNamespace: 'rfe-forms',
1111
- formFieldPath: 'rfe-forms-healthCenter',
1112
- value: '1560AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1113
- });
1114
-
1115
- const commentField = fields.questions[1].questions[0];
1116
- const commentObs = ObsAdapter.transformFieldValue(commentField, 'New test comment', formContext);
1117
- expect(commentObs).toEqual({
1118
- concept: '161011AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1119
- formFieldNamespace: 'rfe-forms',
1120
- formFieldPath: 'rfe-forms-comment',
1121
- value: 'New test comment',
1122
- });
1123
- });
1124
-
1125
- it('should edit existing values in nested groups', () => {
1126
- formContext.sessionMode = 'edit';
1127
- const fields = createNestedFields();
1128
-
1129
- const healthCenterField = fields.questions[0];
1130
- healthCenterField.meta = {
1131
- previousValue: {
1132
- uuid: 'health-center-uuid',
1133
- value: {
1134
- uuid: '1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1135
- },
1136
- },
1137
- };
1138
-
1139
- const healthCenterObs = ObsAdapter.transformFieldValue(
1140
- healthCenterField,
1141
- '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1142
- formContext,
1143
- );
1144
-
1145
- expect(healthCenterObs).toEqual({
1146
- uuid: 'health-center-uuid',
1147
- formFieldNamespace: 'rfe-forms',
1148
- formFieldPath: 'rfe-forms-healthCenter',
1149
- value: '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1150
- });
1151
-
1152
- const commentField = fields.questions[1].questions[0];
1153
- commentField.meta = {
1154
- previousValue: {
1155
- uuid: 'comment-uuid',
1156
- value: 'Test comment for nested group',
1157
- },
1158
- };
1159
-
1160
- const commentObs = ObsAdapter.transformFieldValue(commentField, 'Updated comment text', formContext);
1161
-
1162
- expect(commentObs).toEqual({
1163
- uuid: 'comment-uuid',
1164
- formFieldNamespace: 'rfe-forms',
1165
- formFieldPath: 'rfe-forms-comment',
1166
- value: 'Updated comment text',
1167
- });
1168
- });
1169
-
1170
- it('should void deleted values in nested groups', () => {
1171
- formContext.sessionMode = 'edit';
1172
- const fields = createNestedFields();
1173
-
1174
- const commentField = fields.questions[1].questions[0];
1175
- commentField.meta = {
1176
- previousValue: {
1177
- uuid: 'comment-uuid',
1178
- value: 'Test comment for nested group',
1179
- },
1180
- };
1181
-
1182
- ObsAdapter.transformFieldValue(commentField, '', formContext);
1183
- expect(commentField.meta.submission.voidedValue).toEqual({
1184
- uuid: 'comment-uuid',
1185
- voided: true,
1186
- });
1187
- expect(commentField.meta.submission.newValue).toBe(null);
1188
-
1189
- const diagnosisField = fields.questions[1].questions[1];
1190
- diagnosisField.meta = {
1191
- previousValue: {
1192
- uuid: 'diagnosis-uuid',
1193
- value: {
1194
- uuid: '159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
1195
- },
1196
- },
1197
- };
1198
-
1199
- ObsAdapter.transformFieldValue(diagnosisField, null, formContext);
1200
- expect(diagnosisField.meta.submission.voidedValue).toEqual({
1201
- uuid: 'diagnosis-uuid',
1202
- voided: true,
1203
- });
1204
- expect(diagnosisField.meta.submission.newValue).toBe(null);
1205
- });
1206
-
1207
- it('should handle empty nested groups', async () => {
1208
- const emptyEncounter = {
1209
- uuid: 'encounter-uuid',
1210
- obs: [],
1211
- };
1212
-
1213
- const fields = createNestedFields();
1214
-
1215
- const healthCenterField = fields.questions[0];
1216
- const healthCenterValue = await ObsAdapter.getInitialValue(healthCenterField, emptyEncounter, formContext);
1217
- expect(healthCenterValue).toBe('');
1218
-
1219
- const commentField = fields.questions[1].questions[0];
1220
- const commentValue = await ObsAdapter.getInitialValue(commentField, emptyEncounter, formContext);
1221
- expect(commentValue).toBe('');
1222
- });
1223
- });
@@ -1,23 +1,23 @@
1
1
  import dayjs from 'dayjs';
2
- import { codedTypes, ConceptTrue } from '../constants';
2
+ import { ConceptTrue, codedTypes } from '../constants';
3
3
  import {
4
- type Attachment,
5
- type AttachmentResponse,
4
+ type OpenmrsObs,
6
5
  type FormField,
7
- type FormFieldValueAdapter,
8
6
  type OpenmrsEncounter,
9
- type OpenmrsObs,
7
+ type AttachmentResponse,
8
+ type Attachment,
10
9
  type ValueAndDisplay,
11
10
  } from '../types';
12
11
  import {
12
+ hasRendering,
13
+ gracefullySetSubmission,
13
14
  clearSubmission,
14
15
  flattenObsList,
15
- formatDateAsDisplayString,
16
- gracefullySetSubmission,
17
- hasRendering,
18
16
  parseToLocalDateTime,
17
+ formatDateAsDisplayString,
19
18
  } from '../utils/common-utils';
20
19
  import { type FormContextProps } from '../provider/form-provider';
20
+ import { type FormFieldValueAdapter } from '../types';
21
21
  import { isEmpty } from '../validators/form-validator';
22
22
  import { getAttachmentByUuid } from '../api';
23
23
  import { formatDate, restBaseUrl } from '@openmrs/esm-framework';
@@ -60,45 +60,40 @@ export const ObsAdapter: FormFieldValueAdapter = {
60
60
  if (isEmpty(value)) {
61
61
  return value;
62
62
  }
63
-
64
- if (Array.isArray(value)) {
65
- return value.map((val) => getDisplayValueForSingleObs(field, val)).join(', ');
63
+ if (value instanceof Date) {
64
+ return formatDateAsDisplayString(field, value);
66
65
  }
67
-
68
- return getDisplayValueForSingleObs(field, value);
66
+ if (rendering == 'checkbox') {
67
+ return value.map(
68
+ (selected) => field.questionOptions.answers?.find((option) => option.concept == selected)?.label,
69
+ );
70
+ }
71
+ if (rendering === 'toggle') {
72
+ return value ? field.questionOptions.toggleOptions.labelTrue : field.questionOptions.toggleOptions.labelFalse;
73
+ }
74
+ if (codedTypes.includes(rendering)) {
75
+ return field.questionOptions.answers?.find((option) => option.concept == value)?.label;
76
+ }
77
+ return value;
69
78
  },
70
79
  transformFieldValue: (field: FormField, value: any, context: FormContextProps) => {
71
80
  // clear previous submission
72
81
  clearSubmission(field);
73
-
74
82
  if (!field.meta.previousValue && isEmpty(value)) {
75
83
  return null;
76
84
  }
77
-
78
- // Handle `obsGroup` recursively
79
- if (field.type === 'obsGroup' && Array.isArray(field.questions)) {
80
- const groupObs = field.questions.map((nestedField) =>
81
- ObsAdapter.transformFieldValue(nestedField, nestedField.value, context),
82
- );
83
- return gracefullySetSubmission(field, { groupMembers: groupObs }, undefined);
84
- }
85
-
86
85
  if (hasRendering(field, 'checkbox')) {
87
86
  return handleMultiSelect(field, value);
88
87
  }
89
-
90
88
  if (!isEmpty(value) && hasPreviousObsValueChanged(field, value)) {
91
89
  return gracefullySetSubmission(field, editObs(field, value), undefined);
92
90
  }
93
-
94
91
  if (field.meta.previousValue && isEmpty(value)) {
95
92
  return gracefullySetSubmission(field, undefined, voidObs(field.meta.previousValue));
96
93
  }
97
-
98
94
  if (!isEmpty(value)) {
99
95
  return gracefullySetSubmission(field, constructObs(field, value), undefined);
100
96
  }
101
-
102
97
  return null;
103
98
  },
104
99
  tearDown: function (): void {
@@ -150,46 +145,22 @@ function extractFieldValue(field: FormField, obsList: OpenmrsObs[] = [], makeFie
150
145
  return '';
151
146
  }
152
147
 
153
- /**
154
- * Extracts field's display value
155
- */
156
-
157
- function getDisplayValueForSingleObs(field: FormField, value: any) {
158
- const rendering = field.questionOptions.rendering;
159
-
160
- if (value instanceof Date) {
161
- return formatDateAsDisplayString(field, value);
162
- }
163
- if (rendering == 'checkbox') {
164
- return value.map((selected) => field.questionOptions.answers?.find((option) => option.concept == selected)?.label);
165
- }
166
- if (rendering === 'toggle') {
167
- return value ? field.questionOptions.toggleOptions.labelTrue : field.questionOptions.toggleOptions.labelFalse;
168
- }
169
- if (codedTypes.includes(rendering)) {
170
- return field.questionOptions.answers?.find((option) => option.concept == value)?.label;
171
- }
172
- return value;
173
- }
174
-
175
148
  export function constructObs(field: FormField, value: any): Partial<OpenmrsObs> {
176
149
  if (isEmpty(value) && field.type !== 'obsGroup') {
177
150
  return null;
178
151
  }
179
-
180
- const draftObs: Partial<OpenmrsObs> = {
152
+ const draftObs =
153
+ field.type === 'obsGroup'
154
+ ? { groupMembers: [] }
155
+ : {
156
+ value: field.questionOptions.rendering.startsWith('date') ? formatDateByPickerType(field, value) : value,
157
+ };
158
+ return {
159
+ ...draftObs,
181
160
  concept: field.questionOptions.concept,
182
161
  formFieldNamespace: 'rfe-forms',
183
162
  formFieldPath: `rfe-forms-${field.id}`,
184
163
  };
185
-
186
- if (field.type === 'obsGroup') {
187
- draftObs.groupMembers = value?.map((v: any) => constructObs(field, v)) || [];
188
- } else {
189
- draftObs.value = field.questionOptions.rendering.startsWith('date') ? formatDateByPickerType(field, value) : value;
190
- }
191
-
192
- return draftObs;
193
164
  }
194
165
 
195
166
  export function voidObs(obs: OpenmrsObs) {
@@ -251,11 +222,6 @@ function handleMultiSelect(field: FormField, values: Array<string> = []) {
251
222
  // 2. a mix of both (previous and current)
252
223
  // 3. we only have a current value
253
224
 
254
- // For `obsGroup` types, handle nested groups
255
- if (field.type === 'obsGroup' && Array.isArray(field.questions)) {
256
- return field.questions.map((nestedField) => handleMultiSelect(nestedField, nestedField.value));
257
- }
258
-
259
225
  if (field.meta.previousValue && isEmpty(values)) {
260
226
  // we assume the user cleared the existing value(s)
261
227
  // so we void all previous values
@@ -265,7 +231,6 @@ function handleMultiSelect(field: FormField, values: Array<string> = []) {
265
231
  field.meta.previousValue.map((previousValue) => voidObs(previousValue)),
266
232
  );
267
233
  }
268
-
269
234
  if (field.meta.previousValue && !isEmpty(values)) {
270
235
  const toBeVoided = field.meta.previousValue.filter((obs) => !values.includes(obs.value.uuid));
271
236
  const toBeCreated = values.filter((v) => !field.meta.previousValue.some((obs) => obs.value.uuid === v));
@@ -275,7 +240,6 @@ function handleMultiSelect(field: FormField, values: Array<string> = []) {
275
240
  toBeVoided.map((obs) => voidObs(obs)),
276
241
  );
277
242
  }
278
-
279
243
  return gracefullySetSubmission(
280
244
  field,
281
245
  values.map((value) => constructObs(field, value)),
@@ -1,54 +1,29 @@
1
- import React, { useMemo } from 'react';
1
+ import React from 'react';
2
2
  import classNames from 'classnames';
3
3
  import { type FormFieldInputProps } from '../../types';
4
4
  import styles from './obs-group.scss';
5
- import { FormFieldRenderer, isGroupField } from '../renderer/field/form-field-renderer.component';
5
+ import { FormFieldRenderer } from '../renderer/field/form-field-renderer.component';
6
6
  import { useFormProviderContext } from '../../provider/form-provider';
7
- import { FormGroup } from '@carbon/react';
8
- import { useTranslation } from 'react-i18next';
9
7
 
10
- export const ObsGroup: React.FC<FormFieldInputProps> = ({ field, ...restProps }) => {
11
- const { t } = useTranslation();
8
+ export const ObsGroup: React.FC<FormFieldInputProps> = ({ field }) => {
12
9
  const { formFieldAdapters } = useFormProviderContext();
13
- const showLabel = useMemo(() => field.questions?.length > 1, [field]);
14
10
 
15
- const content = useMemo(
16
- () =>
17
- field.questions
18
- ?.filter((child) => !child.isHidden)
19
- .map((child, index) => {
20
- const keyId = `${child.id}_${index}`;
11
+ const groupContent = field.questions
12
+ ?.filter((child) => !child.isHidden)
13
+ .map((child, index) => {
14
+ const keyId = child.id + '_' + index;
15
+ if (formFieldAdapters[child.type]) {
16
+ return (
17
+ <div className={classNames(styles.flexColumn)} key={keyId}>
18
+ <div className={styles.groupContainer}>
19
+ <FormFieldRenderer fieldId={child.id} valueAdapter={formFieldAdapters[child.type]} />
20
+ </div>
21
+ </div>
22
+ );
23
+ }
24
+ });
21
25
 
22
- if (child.type === 'obsGroup' && isGroupField(child.questionOptions.rendering)) {
23
- return (
24
- <div key={keyId} className={styles.nestedGroupContainer}>
25
- <ObsGroup field={child} {...restProps} />
26
- </div>
27
- );
28
- } else if (formFieldAdapters[child.type]) {
29
- return (
30
- <div className={classNames(styles.flexColumn)} key={keyId}>
31
- <div className={styles.groupContainer}>
32
- <FormFieldRenderer fieldId={child.id} valueAdapter={formFieldAdapters[child.type]} />
33
- </div>
34
- </div>
35
- );
36
- }
37
- }),
38
- [field],
39
- );
40
-
41
- return (
42
- <div className={styles.groupContainer}>
43
- {showLabel ? (
44
- <FormGroup legendText={t(field.label)} className={styles.boldLegend}>
45
- {content}
46
- </FormGroup>
47
- ) : (
48
- content
49
- )}
50
- </div>
51
- );
26
+ return <div className={styles.flexRow}>{groupContent}</div>;
52
27
  };
53
28
 
54
29
  export default ObsGroup;
@@ -10,7 +10,3 @@
10
10
  .groupContainer {
11
11
  margin: 0.5rem 0;
12
12
  }
13
-
14
- .boldLegend > legend {
15
- font-weight: bolder;
16
- }
@@ -1,6 +1,6 @@
1
1
  import { codedTypes } from '../../../constants';
2
2
  import { type FormContextProps } from '../../../provider/form-provider';
3
- import { type FormField } from '../../../types';
3
+ import { type FormFieldValidator, type SessionMode, type ValidationResult, type FormField } from '../../../types';
4
4
  import { isTrue } from '../../../utils/boolean-utils';
5
5
  import { hasRendering } from '../../../utils/common-utils';
6
6
  import { evaluateAsyncExpression, evaluateExpression } from '../../../utils/expression-runner';
@@ -65,6 +65,21 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
65
65
  },
66
66
  ).then((result) => {
67
67
  setValue(dependent.id, result);
68
+ // validate calculated value
69
+ const { errors, warnings } = validateFieldValue(dependent, result, context.formFieldValidators, {
70
+ formFields,
71
+ values,
72
+ expressionContext: { patient, mode: sessionMode },
73
+ });
74
+ if (!dependent.meta.submission) {
75
+ dependent.meta.submission = {};
76
+ }
77
+ dependent.meta.submission.errors = errors;
78
+ dependent.meta.submission.warnings = warnings;
79
+ if (!errors.length) {
80
+ context.formFieldAdapters[dependent.type].transformFieldValue(dependent, result, context);
81
+ }
82
+ updateFormField(dependent);
68
83
  });
69
84
  }
70
85
  // evaluate hide
@@ -212,3 +227,48 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
212
227
  setForm(formJson);
213
228
  }
214
229
  }
230
+
231
+ export interface ValidatorConfig {
232
+ formFields: FormField[];
233
+ values: Record<string, any>;
234
+ expressionContext: {
235
+ patient: fhir.Patient;
236
+ mode: SessionMode;
237
+ };
238
+ }
239
+
240
+ export function validateFieldValue(
241
+ field: FormField,
242
+ value: any,
243
+ validators: Record<string, FormFieldValidator>,
244
+ context: ValidatorConfig,
245
+ ): { errors: ValidationResult[]; warnings: ValidationResult[] } {
246
+ const errors: ValidationResult[] = [];
247
+ const warnings: ValidationResult[] = [];
248
+
249
+ if (field.meta.submission?.unspecified) {
250
+ return { errors: [], warnings: [] };
251
+ }
252
+
253
+ try {
254
+ field.validators.forEach((validatorConfig) => {
255
+ const results = validators[validatorConfig.type]?.validate?.(field, value, {
256
+ ...validatorConfig,
257
+ ...context,
258
+ });
259
+ if (results) {
260
+ results.forEach((result) => {
261
+ if (result.resultType === 'error') {
262
+ errors.push(result);
263
+ } else if (result.resultType === 'warning') {
264
+ warnings.push(result);
265
+ }
266
+ });
267
+ }
268
+ });
269
+ } catch (error) {
270
+ console.error(error);
271
+ }
272
+
273
+ return { errors, warnings };
274
+ }
@@ -21,7 +21,7 @@ import { getFieldControlWithFallback, getRegisteredControl } from '../../../regi
21
21
  import styles from './form-field-renderer.scss';
22
22
  import { isTrue } from '../../../utils/boolean-utils';
23
23
  import UnspecifiedField from '../../inputs/unspecified/unspecified.component';
24
- import { handleFieldLogic } from './fieldLogic';
24
+ import { handleFieldLogic, validateFieldValue } from './fieldLogic';
25
25
 
26
26
  export interface FormFieldRendererProps {
27
27
  fieldId: string;
@@ -221,51 +221,6 @@ function ErrorFallback({ error }) {
221
221
  );
222
222
  }
223
223
 
224
- export interface ValidatorConfig {
225
- formFields: FormField[];
226
- values: Record<string, any>;
227
- expressionContext: {
228
- patient: fhir.Patient;
229
- mode: SessionMode;
230
- };
231
- }
232
-
233
- function validateFieldValue(
234
- field: FormField,
235
- value: any,
236
- validators: Record<string, FormFieldValidator>,
237
- context: ValidatorConfig,
238
- ): { errors: ValidationResult[]; warnings: ValidationResult[] } {
239
- const errors: ValidationResult[] = [];
240
- const warnings: ValidationResult[] = [];
241
-
242
- if (field.meta.submission?.unspecified) {
243
- return { errors: [], warnings: [] };
244
- }
245
-
246
- try {
247
- field.validators.forEach((validatorConfig) => {
248
- const results = validators[validatorConfig.type]?.validate?.(field, value, {
249
- ...validatorConfig,
250
- ...context,
251
- });
252
- if (results) {
253
- results.forEach((result) => {
254
- if (result.resultType === 'error') {
255
- errors.push(result);
256
- } else if (result.resultType === 'warning') {
257
- warnings.push(result);
258
- }
259
- });
260
- }
261
- });
262
- } catch (error) {
263
- console.error(error);
264
- }
265
-
266
- return { errors, warnings };
267
- }
268
-
269
224
  /**
270
225
  * Determines whether a field can be unspecified
271
226
  */
@@ -282,6 +237,6 @@ export function isUnspecifiedSupported(question: FormField) {
282
237
  );
283
238
  }
284
239
 
285
- export function isGroupField(rendering: RenderType) {
240
+ function isGroupField(rendering: RenderType) {
286
241
  return rendering === 'group' || rendering === 'repeating';
287
242
  }
@@ -1,4 +1,5 @@
1
1
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import { FormGroup } from '@carbon/react';
2
3
  import { useTranslation } from 'react-i18next';
3
4
  import type { FormField, FormFieldInputProps, RenderType } from '../../types';
4
5
  import { evaluateAsyncExpression, evaluateExpression } from '../../utils/expression-runner';
@@ -77,7 +78,6 @@ const Repeat: React.FC<FormFieldInputProps> = ({ field }) => {
77
78
  });
78
79
  }
79
80
  }
80
-
81
81
  const clonedField = cloneRepeatField(field, null, counter);
82
82
  // run necessary expressions
83
83
  if (clonedField.type === 'obsGroup') {
@@ -168,7 +168,15 @@ const Repeat: React.FC<FormFieldInputProps> = ({ field }) => {
168
168
 
169
169
  return (
170
170
  <React.Fragment>
171
- <div>{nodes}</div>
171
+ {isGrouped ? (
172
+ <div className={styles.container}>
173
+ <FormGroup legendText={t(field.label)} className={styles.boldLegend}>
174
+ {nodes}
175
+ </FormGroup>
176
+ </div>
177
+ ) : (
178
+ <div>{nodes}</div>
179
+ )}
172
180
  </React.Fragment>
173
181
  );
174
182
  };
@@ -681,6 +681,8 @@ describe('Form engine component', () => {
681
681
 
682
682
  describe('Calculated values', () => {
683
683
  it('should evaluate BMI', async () => {
684
+ const saveEncounterMock = jest.spyOn(api, 'saveEncounter');
685
+
684
686
  await act(async () => renderForm(null, bmiForm));
685
687
 
686
688
  const bmiField = screen.getByRole('textbox', { name: /bmi/i });
@@ -694,9 +696,17 @@ describe('Form engine component', () => {
694
696
  expect(heightField).toHaveValue(150);
695
697
  expect(weightField).toHaveValue(50);
696
698
  expect(bmiField).toHaveValue('22.2');
699
+
700
+ await user.click(screen.getByRole('button', { name: /save/i }));
701
+
702
+ const encounter = saveEncounterMock.mock.calls[0][1];
703
+ expect(encounter.obs.length).toEqual(3);
704
+ expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bmi').value).toBe(22.2);
697
705
  });
698
706
 
699
707
  it('should evaluate BSA', async () => {
708
+ const saveEncounterMock = jest.spyOn(api, 'saveEncounter');
709
+
700
710
  await act(async () => renderForm(null, bsaForm));
701
711
 
702
712
  const bsaField = screen.getByRole('textbox', { name: /bsa/i });
@@ -710,6 +720,12 @@ describe('Form engine component', () => {
710
720
  expect(heightField).toHaveValue(190.5);
711
721
  expect(weightField).toHaveValue(95);
712
722
  expect(bsaField).toHaveValue('2.24');
723
+
724
+ await user.click(screen.getByRole('button', { name: /save/i }));
725
+
726
+ const encounter = saveEncounterMock.mock.calls[0][1];
727
+ expect(encounter.obs.length).toEqual(3);
728
+ expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bsa').value).toBe(2.24);
713
729
  });
714
730
 
715
731
  it('should evaluate EDD', async () => {
@@ -1,45 +1,35 @@
1
1
  import { useMemo } from 'react';
2
- import { type FormField, type FormSchema } from '../types';
2
+ import { type FormSchema, type FormField } from '../types';
3
3
 
4
4
  export function useFormFields(form: FormSchema): { formFields: FormField[]; conceptReferences: Set<string> } {
5
5
  const [flattenedFields, conceptReferences] = useMemo(() => {
6
- const flattenedFieldsTemp: FormField[] = [];
6
+ const flattenedFieldsTemp = [];
7
7
  const conceptReferencesTemp = new Set<string>();
8
-
9
- const flattenFields = (fields: FormField[]) => {
10
- fields.forEach((field) => {
11
- flattenedFieldsTemp.push(field);
12
-
13
- // If the field is an obsGroup, we need to flatten its nested questions
14
- if (field.type === 'obsGroup' && field.questions) {
15
- field.questions.forEach((groupedField) => {
16
- groupedField.meta.groupId = field.id;
17
- flattenFields([groupedField]);
18
- });
19
- }
20
-
21
- // Collect concept references
22
- if (field.questionOptions?.concept) {
23
- conceptReferencesTemp.add(field.questionOptions.concept);
24
- }
25
- if (field.questionOptions?.answers) {
26
- field.questionOptions.answers.forEach((answer) => {
27
- if (answer.concept) {
28
- conceptReferencesTemp.add(answer.concept);
29
- }
30
- });
31
- }
32
- });
33
- };
34
-
35
8
  form.pages?.forEach((page) =>
36
9
  page.sections?.forEach((section) => {
37
- if (section.questions) {
38
- flattenFields(section.questions);
39
- }
10
+ section.questions?.forEach((question) => {
11
+ flattenedFieldsTemp.push(question);
12
+ if (question.type == 'obsGroup') {
13
+ question.questions.forEach((groupedField) => {
14
+ groupedField.meta.groupId = question.id;
15
+ flattenedFieldsTemp.push(groupedField);
16
+ });
17
+ }
18
+ });
40
19
  }),
41
20
  );
42
-
21
+ flattenedFieldsTemp.forEach((field) => {
22
+ if (field.questionOptions?.concept) {
23
+ conceptReferencesTemp.add(field.questionOptions.concept);
24
+ }
25
+ if (field.questionOptions?.answers) {
26
+ field.questionOptions.answers.forEach((answer) => {
27
+ if (answer.concept) {
28
+ conceptReferencesTemp.add(answer.concept);
29
+ }
30
+ });
31
+ }
32
+ });
43
33
  return [flattenedFieldsTemp, conceptReferencesTemp];
44
34
  }, [form]);
45
35
 
@@ -100,7 +100,7 @@ export class EncounterFormProcessor extends FormProcessor {
100
100
  field.meta.fixedValue = field.value;
101
101
  delete field.value;
102
102
  }
103
- if (field.questionOptions?.rendering == 'group' || field.type === 'obsGroup') {
103
+ if (field.questionOptions?.rendering == 'group') {
104
104
  field.questions?.forEach((child) => {
105
105
  child.readonly = child.readonly ?? field.readonly;
106
106
  return prepareFormField(child, section, page, schema);
@@ -1,16 +1,16 @@
1
1
  import {
2
+ type PatientProgram,
2
3
  type FormField,
3
- type FormProcessorContextProps,
4
4
  type OpenmrsEncounter,
5
5
  type OpenmrsObs,
6
6
  type PatientIdentifier,
7
- type PatientProgram,
8
7
  type PatientProgramPayload,
8
+ type FormProcessorContextProps,
9
9
  } from '../../types';
10
10
  import { saveAttachment, savePatientIdentifier, saveProgramEnrollment } from '../../api';
11
11
  import { hasRendering, hasSubmission } from '../../utils/common-utils';
12
12
  import dayjs from 'dayjs';
13
- import { assignedObsIds, constructObs, voidObs } from '../../adapters/obs-adapter';
13
+ import { voidObs, constructObs, assignedObsIds } from '../../adapters/obs-adapter';
14
14
  import { type FormContextProps } from '../../provider/form-provider';
15
15
  import { ConceptTrue } from '../../constants';
16
16
  import { DefaultValueValidator } from '../../validators/default-value-validator';
@@ -185,53 +185,43 @@ export function getMutableSessionProps(context: FormContextProps) {
185
185
  // Helpers
186
186
 
187
187
  function prepareObs(obsForSubmission: OpenmrsObs[], fields: FormField[]) {
188
- fields.filter((field) => hasSubmittableObs(field)).forEach((field) => processObsField(obsForSubmission, field));
189
- }
190
-
191
- function processObsField(obsForSubmission: OpenmrsObs[], field: FormField) {
192
- if ((field.isHidden || field.isParentHidden) && field.meta.previousValue) {
193
- const valuesArray = Array.isArray(field.meta.previousValue) ? field.meta.previousValue : [field.meta.previousValue];
194
- addObsToList(
195
- obsForSubmission,
196
- valuesArray.map((obs) => voidObs(obs)),
197
- );
198
- return;
199
- }
200
-
201
- if (field.type === 'obsGroup') {
202
- processObsGroup(obsForSubmission, field);
203
- } else if (hasSubmission(field)) {
204
- // For non-group obs with a submission
205
- addObsToList(obsForSubmission, field.meta.submission.newValue);
206
- addObsToList(obsForSubmission, field.meta.submission.voidedValue);
207
- }
208
- }
209
-
210
- function processObsGroup(obsForSubmission: OpenmrsObs[], groupField: FormField) {
211
- if (groupField.meta.submission?.voidedValue) {
212
- addObsToList(obsForSubmission, groupField.meta.submission.voidedValue);
213
- return;
214
- }
215
-
216
- const obsGroup = constructObs(groupField, null);
217
- if (groupField.meta.previousValue) {
218
- obsGroup.uuid = groupField.meta.previousValue.uuid;
219
- }
220
-
221
- groupField.questions.forEach((nestedField) => {
222
- if (nestedField.type === 'obsGroup') {
223
- const nestedObsGroup: OpenmrsObs[] = [];
224
- processObsGroup(nestedObsGroup, nestedField);
225
- addObsToList(obsGroup.groupMembers, nestedObsGroup);
226
- } else if (hasSubmission(nestedField)) {
227
- addObsToList(obsGroup.groupMembers, nestedField.meta.submission.newValue);
228
- addObsToList(obsGroup.groupMembers, nestedField.meta.submission.voidedValue);
229
- }
230
- });
231
-
232
- if (obsGroup.groupMembers?.length || obsGroup.voided) {
233
- addObsToList(obsForSubmission, obsGroup);
234
- }
188
+ fields
189
+ .filter((field) => hasSubmittableObs(field))
190
+ .forEach((field) => {
191
+ if ((field.isHidden || field.isParentHidden) && field.meta.previousValue) {
192
+ const valuesArray = Array.isArray(field.meta.previousValue)
193
+ ? field.meta.previousValue
194
+ : [field.meta.previousValue];
195
+ addObsToList(
196
+ obsForSubmission,
197
+ valuesArray.map((obs) => voidObs(obs)),
198
+ );
199
+ return;
200
+ }
201
+ if (field.type == 'obsGroup') {
202
+ if (field.meta.submission?.voidedValue) {
203
+ addObsToList(obsForSubmission, field.meta.submission.voidedValue);
204
+ return;
205
+ }
206
+ const obsGroup = constructObs(field, null);
207
+ if (field.meta.previousValue) {
208
+ obsGroup.uuid = field.meta.previousValue.uuid;
209
+ }
210
+ field.questions.forEach((groupedField) => {
211
+ if (hasSubmission(groupedField)) {
212
+ addObsToList(obsGroup.groupMembers, groupedField.meta.submission.newValue);
213
+ addObsToList(obsGroup.groupMembers, groupedField.meta.submission.voidedValue);
214
+ }
215
+ });
216
+ if (obsGroup.groupMembers.length || obsGroup.voided) {
217
+ addObsToList(obsForSubmission, obsGroup);
218
+ }
219
+ }
220
+ if (hasSubmission(field)) {
221
+ addObsToList(obsForSubmission, field.meta.submission.newValue);
222
+ addObsToList(obsForSubmission, field.meta.submission.voidedValue);
223
+ }
224
+ });
235
225
  }
236
226
 
237
227
  function prepareOrders(fields: FormField[]) {
@@ -1,4 +1,4 @@
1
- import { type FormField, type FormSchema, type FormSchemaTransformer, type RenderType } from '../types';
1
+ import { type FormField, type FormSchemaTransformer, type FormSchema, type RenderType } from '../types';
2
2
  import { isTrue } from '../utils/boolean-utils';
3
3
  import { hasRendering } from '../utils/common-utils';
4
4
 
@@ -39,13 +39,8 @@ function handleQuestion(question: FormField, form: FormSchema) {
39
39
  setFieldValidators(question);
40
40
  transformByType(question);
41
41
  transformByRendering(question);
42
-
43
- if (question.questions?.length) {
44
- if (question.type === 'obsGroup' && question.questions.length) {
45
- question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, form));
46
- } else {
47
- question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, form));
48
- }
42
+ if (question?.questions?.length) {
43
+ question.questions.forEach((question) => handleQuestion(question, form));
49
44
  }
50
45
  } catch (error) {
51
46
  console.error(error);