diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt index e21d3449e9..f4fb20e89e 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt @@ -47,6 +47,8 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.ValueSet import timber.log.Timber @@ -61,7 +63,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat /** The current questionnaire as questions are being answered. */ internal val questionnaire: Questionnaire - private lateinit var currentPageItems: List init { questionnaire = @@ -94,15 +95,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat /** The current questionnaire response as questions are being answered. */ private val questionnaireResponse: QuestionnaireResponse - /** - * True if the user has tapped the next/previous pagination buttons on the current page. This is - * needed to avoid spewing validation errors before any questions are answered. - */ - private var isPaginationButtonPressed = false - - /** Forces response validation each time [getQuestionnaireAdapterItems] is called. */ - private var hasPressedSubmitButton = false - init { when { state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI) -> { @@ -192,13 +184,33 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat private val isInReviewModeFlow = MutableStateFlow(shouldShowReviewPageFirst) /** - * Contains [QuestionnaireResponse.QuestionnaireResponseItemComponent]s that have been modified by - * the user. [QuestionnaireResponse.QuestionnaireResponseItemComponent]s that have not been - * modified by the user will not be validated. This is to avoid spamming the user with a sea of - * validation errors when the questionnaire is loaded initially. + * Contains [QuestionnaireResponseItemComponent]s that have been modified by the user. + * [QuestionnaireResponseItemComponent]s that have not been modified by the user will not be + * validated. This is to avoid spamming the user with a sea of validation errors when the + * questionnaire is loaded initially. */ private val modifiedQuestionnaireResponseItemSet = - mutableSetOf() + mutableSetOf() + + private lateinit var currentPageItems: List + + /** + * True if the user has tapped the next/previous pagination buttons on the current page. This is + * needed to avoid spewing validation errors before any questions are answered. + */ + private var isPaginationButtonPressed = false + + /** Forces response validation each time [getQuestionnaireAdapterItems] is called. */ + private var hasPressedSubmitButton = false + + /** + * Map of [QuestionnaireResponseItemAnswerComponent] for + * [Questionnaire.QuestionnaireItemComponent]s that are disabled now. The answers will be used to + * pre-populate the [QuestionnaireResponseItemComponent] once the item is enabled again. + */ + private val responseItemToAnswersMapForDisabledQuestionnaireItem = + mutableMapOf< + QuestionnaireResponseItemComponent, List>() /** * Callback function to update the view model after the answer(s) to a question have been changed. @@ -211,16 +223,15 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat * * This callback function has 3 params: * - the reference to the [Questionnaire.QuestionnaireItemComponent] in the [Questionnaire] - * - the reference to the [QuestionnaireResponse.QuestionnaireResponseItemComponent] in the - * [QuestionnaireResponse] - * - a [List] of [QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent] which are the - * new answers to the question. + * - the reference to the [QuestionnaireResponseItemComponent] in the [QuestionnaireResponse] + * - a [List] of [QuestionnaireResponseItemAnswerComponent] which are the new answers to the + * question. */ private val answersChangedCallback: ( Questionnaire.QuestionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent, - List, + QuestionnaireResponseItemComponent, + List, ) -> Unit = { questionnaireItem, questionnaireResponseItem, answers -> // TODO(jingtang10): update the questionnaire response item pre-order list and the parent map @@ -377,9 +388,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat if (questionnaireResponseItem.answer.hasDifferentAnswerSet(calculatedAnswers)) { questionnaireResponseItem.answer = calculatedAnswers.map { - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = it - } + QuestionnaireResponseItemAnswerComponent().apply { value = it } } } } @@ -537,7 +546,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat */ private fun getQuestionnaireAdapterItems( questionnaireItemList: List, - questionnaireResponseItemList: List, + questionnaireResponseItemList: List, ): List { var responseIndex = 0 return questionnaireItemList @@ -564,16 +573,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat */ private fun getQuestionnaireAdapterItems( questionnaireItem: Questionnaire.QuestionnaireItemComponent, - questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, ): List { - // Disabled/hidden questions should not get QuestionnaireItemViewItem instances + // Hidden questions should not get QuestionnaireItemViewItem instances + if (questionnaireItem.isHidden) return emptyList() val enabled = EnablementEvaluator(questionnaireResponse) .evaluate(questionnaireItem, questionnaireResponseItem) - if (!enabled || questionnaireItem.isHidden) { + // Disabled questions should not get QuestionnaireItemViewItem instances + if (!enabled) { + cacheDisabledQuestionnaireItemAnswers(questionnaireResponseItem) return emptyList() } + restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem) + // Determine the validation result, which will be displayed on the item itself val validationResult = if (modifiedQuestionnaireResponseItemSet.contains(questionnaireResponseItem) || @@ -603,7 +617,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) ) ) - val nestedResponses: List> = + val nestedResponses: List> = when { // Repeated questions have one answer item per response instance, which we must display // after the question. @@ -630,10 +644,37 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat return items } + /** + * If the item is not enabled, clear the answers that it may have from the previous enabled state. + * This will also prevent any questionnaire item that depends on the answer of this questionnaire + * item to be wrongly evaluated as well. + */ + private fun cacheDisabledQuestionnaireItemAnswers( + questionnaireResponseItem: QuestionnaireResponseItemComponent + ) { + if (questionnaireResponseItem.hasAnswer()) { + responseItemToAnswersMapForDisabledQuestionnaireItem[questionnaireResponseItem] = + questionnaireResponseItem.answer + questionnaireResponseItem.answer = listOf() + } + } + + /** + * If the questionnaire item was previously disabled, check the cache to restore previous answers. + */ + private fun restoreFromDisabledQuestionnaireItemAnswersCache( + questionnaireResponseItem: QuestionnaireResponseItemComponent + ) { + if (responseItemToAnswersMapForDisabledQuestionnaireItem.contains(questionnaireResponseItem)) { + questionnaireResponseItem.answer = + responseItemToAnswersMapForDisabledQuestionnaireItem.remove(questionnaireResponseItem) + } + } + private fun getEnabledResponseItems( questionnaireItemList: List, - questionnaireResponseItemList: List, - ): List { + questionnaireResponseItemList: List, + ): List { val enablementEvaluator = EnablementEvaluator(questionnaireResponse) val responseItemKeys = questionnaireResponseItemList.map { it.linkId } return questionnaireItemList @@ -680,15 +721,15 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat */ private fun createRepeatedGroupResponse( questionnaireItem: Questionnaire.QuestionnaireItemComponent, - questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, - ): List { + questionnaireResponseItem: QuestionnaireResponseItemComponent, + ): List { val individualQuestions = questionnaireItem.item return questionnaireResponseItem.answer.map { repeatedGroupInstance -> val responsesToIndividualQuestions = repeatedGroupInstance.item check(responsesToIndividualQuestions.size == individualQuestions.size) { "Repeated groups responses must have the same # of responses as the group has questions" } - QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + QuestionnaireResponseItemComponent().apply { linkId = questionnaireItem.linkId text = questionnaireItem.localizedTextSpanned?.toString() item = diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt index b4414f838a..a1c469ca72 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt @@ -1112,6 +1112,208 @@ class QuestionnaireViewModelTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } + @Test + fun `should disable all questions in a chain of dependent questions after top question is disabled`() { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "question-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "question-2" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + addEnableWhen().apply { + answer = BooleanType(true) + question = "question-1" + operator = Questionnaire.QuestionnaireItemOperator.EQUAL + } + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "question-3" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + addEnableWhen().apply { + answer = BooleanType(true) + question = "question-2" + operator = Questionnaire.QuestionnaireItemOperator.EQUAL + } + } + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-1" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + } + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-2" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + } + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-3" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + } + ) + } + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) + state.set( + EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, + printer.encodeResourceToString(questionnaireResponse) + ) + + val viewModel = QuestionnaireViewModel(context, state) + viewModel.runViewModelBlocking { + var items = viewModel.getQuestionnaireItemViewItemList().map { it.asQuestion() } + assertThat(items.map { it.questionnaireItem.linkId }) + .containsExactly("question-1", "question-2", "question-3") + + items.first { it.questionnaireItem.linkId == "question-1" }.clearAnswer() + + items = viewModel.getQuestionnaireItemViewItemList().map { it.asQuestion() } + assertThat(items.map { it.questionnaireItem.linkId }).containsExactly("question-1") + } + } + + @Test + fun `should restore previous state in a chain of dependent question items when item is disabled and enabled`() { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "question-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "question-2" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + addEnableWhen().apply { + answer = BooleanType(true) + question = "question-1" + operator = Questionnaire.QuestionnaireItemOperator.EQUAL + } + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "question-3" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + addEnableWhen().apply { + answer = BooleanType(true) + question = "question-2" + operator = Questionnaire.QuestionnaireItemOperator.EQUAL + } + } + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-1" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + } + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-2" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + } + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-3" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + } + ) + } + + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) + state.set( + EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING, + printer.encodeResourceToString(questionnaireResponse) + ) + + val viewModel = QuestionnaireViewModel(context, state) + viewModel.runViewModelBlocking { + val items = viewModel.getQuestionnaireItemViewItemList().map { it.asQuestion() } + // Clearing the answer disables question-2 that in turn disables question-3. + items.first { it.questionnaireItem.linkId == "question-1" }.clearAnswer() + + assertResourceEquals( + viewModel.getQuestionnaireResponse(), + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "question-1" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(false) + } + ) + } + ) + } + ) + + // Setting the answer of "question-1" to true should enable question-2 that in turn enables + // question-3 and restore their previous states. + items + .first { it.questionnaireItem.linkId == "question-1" } + .addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = BooleanType(true) + } + ) + + assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) + } + } + // Test cases for state flow @Test