diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidator.kt index e7974af075..900eb3c4fb 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidator.kt @@ -27,12 +27,12 @@ internal object RequiredConstraintValidator : ConstraintValidator { questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, context: Context ): ConstraintValidator.ConstraintValidationResult { - if (questionnaireItem.required && questionnaireResponseItem.answer.isEmpty()) { - return ConstraintValidator.ConstraintValidationResult( - false, - context.getString(R.string.required_constraint_validation_error_msg) - ) + if (!questionnaireItem.required || questionnaireResponseItem.answer.any { it.hasValue() }) { + return ConstraintValidator.ConstraintValidationResult(true, null) } - return ConstraintValidator.ConstraintValidationResult(true, null) + return ConstraintValidator.ConstraintValidationResult( + false, + context.getString(R.string.required_constraint_validation_error_msg) + ) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextStringViewHolderDelegate.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextStringViewHolderDelegate.kt index 85c53289db..6fdd7bbc4d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextStringViewHolderDelegate.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextStringViewHolderDelegate.kt @@ -35,13 +35,8 @@ internal class QuestionnaireItemEditTextStringViewHolderDelegate(isSingleLine: B override fun getValue( text: String ): QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent? { - return text.let { - if (it.isEmpty()) { - null - } else { - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(StringType(it)) - } - } + return QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(StringType(text)) } override fun getText( diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt index 83101a1259..9ce4ebe4a8 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt @@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.views import android.content.Context import android.text.Editable +import android.text.TextWatcher import android.view.View import android.view.View.FOCUS_DOWN import android.view.inputmethod.EditorInfo @@ -45,6 +46,7 @@ internal abstract class QuestionnaireItemEditTextViewHolderDelegate( private lateinit var textInputLayout: TextInputLayout private lateinit var textInputEditText: TextInputEditText override lateinit var questionnaireItemViewItem: QuestionnaireItemViewItem + private var textWatcher: TextWatcher? = null override fun init(itemView: View) { header = itemView.findViewById(R.id.header) @@ -52,16 +54,22 @@ internal abstract class QuestionnaireItemEditTextViewHolderDelegate( textInputEditText = itemView.findViewById(R.id.text_input_edit_text) textInputEditText.setRawInputType(rawInputType) textInputEditText.isSingleLine = isSingleLine - textInputEditText.doAfterTextChanged { editable: Editable? -> - questionnaireItemViewItem.singleAnswerOrNull = getValue(editable.toString()) - onAnswerChanged(textInputEditText.context) - } } override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) { header.bind(questionnaireItemViewItem.questionnaireItem) textInputLayout.hint = questionnaireItemViewItem.questionnaireItem.localizedFlyoverSpanned - textInputEditText.setText(getText(questionnaireItemViewItem.singleAnswerOrNull)) + textInputEditText.removeTextChangedListener(textWatcher) + + val answer = questionnaireItemViewItem.singleAnswerOrNull + if (answer == null) { + // Clear the text input and any error message if the question has not been answered. + textInputEditText.setText("") + displayValidationResult(ValidationResult(true, listOf())) + } else { + textInputEditText.setText(getText(answer)) + onAnswerChanged(textInputEditText.context) + } textInputEditText.setOnFocusChangeListener { view, focused -> if (!focused) { (view.context.applicationContext.getSystemService(Context.INPUT_METHOD_SERVICE) as @@ -79,6 +87,11 @@ internal abstract class QuestionnaireItemEditTextViewHolderDelegate( } view.focusSearch(FOCUS_DOWN)?.requestFocus(FOCUS_DOWN) ?: false } + textWatcher = + textInputEditText.doAfterTextChanged { editable: Editable? -> + questionnaireItemViewItem.singleAnswerOrNull = getValue(editable.toString()) + onAnswerChanged(textInputEditText.context) + } } override fun displayValidationResult(validationResult: ValidationResult) { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt index 09c5e05d41..cbd07559f2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt @@ -62,7 +62,16 @@ open class QuestionnaireItemViewHolder( delegate.questionnaireItemViewItem = questionnaireItemViewItem delegate.bind(questionnaireItemViewItem) delegate.setReadOnly(questionnaireItemViewItem.questionnaireItem.readOnly) - delegate.displayValidationResult(delegate.getValidationResult(itemView.context)) + // Only validate questionnaire items with answer(s). This is so that we do not show all the + // validation errors at once when the user opens a new questionnaire for the first time. + // Instead, the validation errors are shown when the user goes through each question. + // Notice the difference between a questionnaire response item without answer, and a + // questionnaire with an answer without value. + if (delegate.questionnaireItemViewItem.modified || + delegate.questionnaireItemViewItem.questionnaireResponseItem.answer.size > 0 + ) { + delegate.displayValidationResult(delegate.getValidationResult(itemView.context)) + } } } @@ -101,6 +110,9 @@ interface QuestionnaireItemViewHolderDelegate { */ fun onAnswerChanged(context: Context) { questionnaireItemViewItem.questionnaireResponseItemChangedCallback() + // purpose of this field is to let the validation execute ( if the answer has been added, this + // tells that the User has made an interaction to that particular input field + questionnaireItemViewItem.modified = true displayValidationResult(getValidationResult(context)) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewItem.kt index 96438cd03a..56820115ff 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewItem.kt @@ -35,6 +35,10 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse * @param questionnaireResponseItemChangedCallback function that should be called whenever the * `questionnaireResponseItemBuilder` is changed to inform the rest of the questionnaire to be * updated + * + * @param modified True once user has interacted with the questionnaire item (when `onAnswerChanged` + * triggers). Only modified fields will be validated. This is to avoid an influx of validation + * errors when the user first opens the questionnaire. */ data class QuestionnaireItemViewItem( val questionnaireItem: Questionnaire.QuestionnaireItemComponent, @@ -44,6 +48,7 @@ data class QuestionnaireItemViewItem( { emptyList() }, + var modified: Boolean = false, val questionnaireResponseItemChangedCallback: () -> Unit ) { /** diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidatorTest.kt index 31e770f3a7..2f805f6bbe 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RequiredConstraintValidatorTest.kt @@ -66,4 +66,25 @@ class RequiredConstraintValidatorTest { assertThat(validationResult.isValid).isFalse() assertThat(validationResult.message).isEqualTo("Missing answer for required field.") } + + @Test + fun shouldReturnInvalidResult_noAnswerHasValue() { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { required = true } + val questionnaireResponseItem = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + // one answer with no value + addAnswer(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent()) + // second answer with no value + addAnswer(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent()) + } + + val validationResult = + RequiredConstraintValidator.validate( + questionnaireItem, + questionnaireResponseItem, + InstrumentationRegistry.getInstrumentation().context + ) + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.message).isEqualTo("Missing answer for required field.") + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemAutoCompleteViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemAutoCompleteViewHolderFactoryTest.kt index bc05398b98..65dedc9d05 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemAutoCompleteViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemAutoCompleteViewHolderFactoryTest.kt @@ -19,9 +19,11 @@ package com.google.android.fhir.datacapture.views import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView +import androidx.core.view.children import androidx.core.view.get import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.displayString +import com.google.android.material.chip.Chip import com.google.android.material.textfield.TextInputLayout import com.google.common.truth.Truth.assertThat import org.hl7.fhir.r4.model.Coding @@ -162,7 +164,8 @@ class QuestionnaireItemAutoCompleteViewHolderFactoryInstrumentedTest { viewHolder.bind( QuestionnaireItemViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) @@ -170,6 +173,35 @@ class QuestionnaireItemAutoCompleteViewHolderFactoryInstrumentedTest { .isEqualTo("Missing answer for required field.") } + @Test + fun displayValidationResult_showErrorWhenAnswersAreRemoved() { + val questionnaire = + Questionnaire.QuestionnaireItemComponent().apply { + required = true + addAnswerOption( + Questionnaire.QuestionnaireItemAnswerOptionComponent().apply { + value = Coding().apply { display = "display" } + } + ) + } + val questionnaireResponseWithAnswer = + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = Coding().apply { display = "display" } + } + ) + } + + viewHolder.bind(QuestionnaireItemViewItem(questionnaire, questionnaireResponseWithAnswer) {}) + + (viewHolder.itemView.findViewById(R.id.flexboxLayout).children.first() as Chip) + .performCloseIconClick() + + assertThat(viewHolder.itemView.findViewById(R.id.text_input_layout).error) + .isEqualTo("Missing answer for required field.") + } + @Test fun displayValidationResult_noError_shouldShowNoErrorMessage() { viewHolder.bind( diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemBooleanTypePickerViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemBooleanTypePickerViewHolderFactoryTest.kt index e1b9bb8880..8898b42d18 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemBooleanTypePickerViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemBooleanTypePickerViewHolderFactoryTest.kt @@ -280,7 +280,8 @@ class QuestionnaireItemBooleanTypePickerViewHolderFactoryTest { viewHolder.bind( QuestionnaireItemViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemCheckBoxGroupViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemCheckBoxGroupViewHolderFactoryTest.kt index 7899c601c9..c526e70745 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemCheckBoxGroupViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemCheckBoxGroupViewHolderFactoryTest.kt @@ -343,7 +343,8 @@ class QuestionnaireItemCheckBoxGroupViewHolderFactoryTest { repeats = true required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactoryTest.kt index d0ca6e0304..61e4a92539 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactoryTest.kt @@ -107,7 +107,8 @@ class QuestionnaireItemDateTimePickerViewHolderFactoryTest { viewHolder.bind( QuestionnaireItemViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDropDownViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDropDownViewHolderFactoryTest.kt index 7179c98465..02c7502b5f 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDropDownViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDropDownViewHolderFactoryTest.kt @@ -189,7 +189,8 @@ class QuestionnaireItemDropDownViewHolderFactoryTest { viewHolder.bind( QuestionnaireItemViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextMultiLineViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextMultiLineViewHolderFactoryTest.kt index 75dc931ab4..baa4182dc6 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextMultiLineViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextMultiLineViewHolderFactoryTest.kt @@ -133,7 +133,6 @@ class QuestionnaireItemEditTextMultiLineViewHolderFactoryTest { ) {} viewHolder.bind(questionnaireItemViewItem) - viewHolder.itemView.findViewById(R.id.text_input_edit_text).setText("") assertThat(questionnaireItemViewItem.questionnaireResponseItem.answer.size).isEqualTo(0) } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt index 34ae56352e..7c900e930b 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt @@ -156,7 +156,8 @@ class QuestionnaireItemEditTextQuantityViewHolderFactoryTest { viewHolder.bind( QuestionnaireItemViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextSingleLineViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextSingleLineViewHolderFactoryTest.kt index 49ad8ce28a..7f71f51b41 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextSingleLineViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextSingleLineViewHolderFactoryTest.kt @@ -131,7 +131,6 @@ class QuestionnaireItemEditTextSingleLineViewHolderFactoryTest { ) {} viewHolder.bind(questionnaireItemViewItem) - viewHolder.itemView.findViewById(R.id.text_input_edit_text).setText("") assertThat(questionnaireItemViewItem.questionnaireResponseItem.answer.size).isEqualTo(0) } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactoryTest.kt index 6965128faa..d9d5ca4b1e 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactoryTest.kt @@ -55,7 +55,8 @@ class QuestionnaireItemGroupViewHolderFactoryTest { viewHolder.bind( QuestionnaireItemViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemRadioGroupViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemRadioGroupViewHolderFactoryTest.kt index 42a139bd08..d3d15aa22c 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemRadioGroupViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemRadioGroupViewHolderFactoryTest.kt @@ -306,7 +306,8 @@ class QuestionnaireItemRadioGroupViewHolderFactoryTest { viewHolder.bind( QuestionnaireItemViewItem( Questionnaire.QuestionnaireItemComponent().apply { required = true }, - QuestionnaireResponse.QuestionnaireResponseItemComponent() + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + modified = true ) {} ) diff --git a/demo/src/main/assets/new-patient-registration-paginated.json b/demo/src/main/assets/new-patient-registration-paginated.json index bab5ce498e..e3a9cedf64 100644 --- a/demo/src/main/assets/new-patient-registration-paginated.json +++ b/demo/src/main/assets/new-patient-registration-paginated.json @@ -383,6 +383,74 @@ } ] } + }, + { + "type": "choice", + "repeats": true, + "code": [], + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "autocomplete", + "display": "Auto-complete" + } + ], + "text": "Auto-complete" + } + } + ], + "required": true, + "linkId": "4", + "text": "Do you have any existing conditions", + "prefix": "Q4:", + "answerOption": [ + { + "valueCoding": { + "code": "asthma", + "display": "Asthma" + } + }, + { + "valueCoding": { + "code": "copd", + "display": "Chronic Lung Disease" + } + }, + { + "valueCoding": { + "code": "depression", + "display": "Depression" + } + }, + { + "valueCoding": { + "code": "t2dm", + "display": "Diabetes" + } + }, + { + "valueCoding": { + "code": "hypertension", + "display": "Hypertension" + } + }, + { + "valueCoding": { + "code": "hypertension", + "display": "High Blood Pressure" + } + }, + { + "valueCoding": { + "code": "hypercholesterolaemia", + "display": "High Cholesterol" + } + } + ] } ] }