From 32939ceda2da8c389efb12376ee42ef47019df0b Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Wed, 17 May 2023 10:30:14 -0400 Subject: [PATCH] Cqf expression implementation for dynamic question title (#1959) * CQF Expression implementation for question title * Fix issue with failing tests * Add tests | Fix variables expression logic issue * Remove extra javadoc * Remove function call from questionItem and use response text for dynamic eval * Update datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt Co-authored-by: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> * Update datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt Co-authored-by: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> * Update catalog/src/main/res/values/strings.xml Co-authored-by: Jing Tang * Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt Co-authored-by: Jing Tang * rename catalog files | add tests | add to group view item | modify same item check * Update catalog/src/main/res/values/strings.xml Co-authored-by: Jing Tang * Update datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt Co-authored-by: Jing Tang * Update datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt Co-authored-by: Jing Tang * Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt Co-authored-by: Jing Tang * Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt Co-authored-by: Jing Tang * Move code to MoreElements * Make expression eval generic method * Add android test for cqf --------- Co-authored-by: Benjamin Mwalimu Co-authored-by: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> Co-authored-by: Jing Tang --- .../behavior_dynamic_question_text.json | 68 +++++++++ .../fhir/catalog/BehaviorListViewModel.kt | 5 + .../res/drawable/ic_dynamic_text_behavior.xml | 30 ++++ catalog/src/main/res/values/strings.xml | 3 + ...stionnaire_with_dynamic_question_text.json | 56 +++++++ .../QuestionnaireUiEspressoTest.kt | 24 +++ .../datacapture/QuestionnaireEditAdapter.kt | 2 +- .../datacapture/QuestionnaireViewModel.kt | 47 +++++- .../datacapture/extensions/MoreElements.kt | 30 ++++ .../fhirpath/ExpressionEvaluator.kt | 66 ++++++--- .../fhir/datacapture/views/GroupHeaderView.kt | 4 +- .../fhir/datacapture/views/HeaderView.kt | 4 +- .../views/QuestionnaireViewItem.kt | 23 ++- .../datacapture/QuestionnaireViewModelTest.kt | 140 ++++++++++++++++++ .../fhirpath/ExpressionEvaluatorTest.kt | 2 + .../datacapture/views/GroupHeaderViewTest.kt | 41 ++++- .../fhir/datacapture/views/HeaderViewTest.kt | 41 ++++- .../views/QuestionnaireViewItemTest.kt | 66 +++++++-- 18 files changed, 602 insertions(+), 50 deletions(-) create mode 100644 catalog/src/main/assets/behavior_dynamic_question_text.json create mode 100644 catalog/src/main/res/drawable/ic_dynamic_text_behavior.xml create mode 100644 datacapture/sampledata/questionnaire_with_dynamic_question_text.json create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreElements.kt diff --git a/catalog/src/main/assets/behavior_dynamic_question_text.json b/catalog/src/main/assets/behavior_dynamic_question_text.json new file mode 100644 index 0000000000..50af5eca49 --- /dev/null +++ b/catalog/src/main/assets/behavior_dynamic_question_text.json @@ -0,0 +1,68 @@ +{ + "resourceType": "Questionnaire", + "item": [ + { + "text": "Choose an option below", + "type": "choice", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "radio-button", + "display": "Radio Button" + } + ] + } + } + ], + "linkId": "1", + "required": true, + "answerOption": [ + { + "valueCoding": { + "code": "option1", + "display": "First Option" + } + }, + { + "valueCoding": { + "code": "option2", + "display": "Second Option" + } + }, + { + "valueCoding": { + "code": "option3", + "display": "Third Option" + } + }, + { + "valueCoding": { + "code": "option4", + "display": "Fourth Option" + } + } + ] + }, + { + "linkId": "2", + "required": true, + "text": "Option Date", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/cqf-expression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "'Provide \"'+ %resource.descendants().where(linkId = '1').answer.value.display + '\" Date'" + } + } + ] + }, + "type": "date" + } + ] +} \ No newline at end of file diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt b/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt index d5e943171f..0f5ffe3c16 100644 --- a/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt +++ b/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt @@ -47,6 +47,11 @@ class BehaviorListViewModel(application: Application) : AndroidViewModel(applica R.drawable.ic_skiplogic_behavior, R.string.behavior_name_skip_logic, "behavior_skip_logic.json" + ), + DYNAMIC_QUESTION_TEXT( + R.drawable.ic_dynamic_text_behavior, + R.string.behavior_name_dynamic_question_text, + "behavior_dynamic_question_text.json" ) } } diff --git a/catalog/src/main/res/drawable/ic_dynamic_text_behavior.xml b/catalog/src/main/res/drawable/ic_dynamic_text_behavior.xml new file mode 100644 index 0000000000..e47e63e483 --- /dev/null +++ b/catalog/src/main/res/drawable/ic_dynamic_text_behavior.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index 063cc5bb1e..6ab1e08526 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -44,6 +44,9 @@ name="behavior_name_skip_logic_info" >If Yes is selected, a follow-up question is displayed. If No is selected, no follow-up questions are displayed. Context variable + Dynamic question text Input age to automatically calculate birthdate until birthdate is updated manually. diff --git a/datacapture/sampledata/questionnaire_with_dynamic_question_text.json b/datacapture/sampledata/questionnaire_with_dynamic_question_text.json new file mode 100644 index 0000000000..f5b2f56879 --- /dev/null +++ b/datacapture/sampledata/questionnaire_with_dynamic_question_text.json @@ -0,0 +1,56 @@ +{ + "resourceType": "Questionnaire", + "item": [ + { + "text": "Choose an option below", + "type": "choice", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "radio-button", + "display": "Radio Button" + } + ] + } + } + ], + "linkId": "1", + "required": true, + "answerOption": [ + { + "valueCoding": { + "code": "option1", + "display": "First Option" + } + }, + { + "valueCoding": { + "code": "option2", + "display": "Second Option" + } + } + ] + }, + { + "linkId": "2", + "required": true, + "text": "Option Date", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/cqf-expression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "'Provide \"'+ %resource.descendants().where(linkId = '1').answer.value.display + '\" Date'" + } + } + ] + }, + "type": "date" + } + ] +} \ No newline at end of file diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/QuestionnaireUiEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/QuestionnaireUiEspressoTest.kt index 4d28ef9ff1..0f10902ec6 100644 --- a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/QuestionnaireUiEspressoTest.kt +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/QuestionnaireUiEspressoTest.kt @@ -27,6 +27,7 @@ import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.matcher.RootMatchers import androidx.test.espresso.matcher.ViewMatchers import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry @@ -454,6 +455,29 @@ class QuestionnaireUiEspressoTest { } } + @Test + fun cqfExpression_shouldSetText_withEvaluatedAnswer() { + buildFragmentFromQuestionnaire("/questionnaire_with_dynamic_question_text.json") + + onView(CoreMatchers.allOf(withText("Option Date"))).check { view, _ -> + assertThat(view.id).isEqualTo(R.id.question) + } + + onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> + assertThat(view).isNull() + } + + onView(CoreMatchers.allOf(withText("First Option"))).perform(ViewActions.click()) + + onView(CoreMatchers.allOf(withText("Option Date"))).check { view, _ -> + assertThat(view).isNull() + } + + onView(CoreMatchers.allOf(withText("Provide \"First Option\" Date"))).check { view, _ -> + assertThat(view.id).isEqualTo(R.id.question) + } + } + private fun buildFragmentFromQuestionnaire(fileName: String, isReviewMode: Boolean = false) { val questionnaireJsonString = readFileFromAssets(fileName) val questionnaireFragment = diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt index b3ecb6cffc..540c4e65b9 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireEditAdapter.kt @@ -292,7 +292,7 @@ internal object DiffCallbacks { newItem: QuestionnaireAdapterItem.Question, ): Boolean { return oldItem.item.hasTheSameItem(newItem.item) && - oldItem.item.hasTheSameAnswer(newItem.item) && + oldItem.item.hasTheSameResponse(newItem.item) && oldItem.item.hasTheSameValidationResult(newItem.item) } } 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 ad6f9cff6b..9119a69a7d 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 @@ -31,6 +31,7 @@ import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer import com.google.android.fhir.datacapture.extensions.allItems import com.google.android.fhir.datacapture.extensions.answerExpression +import com.google.android.fhir.datacapture.extensions.cqfExpression import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.extensions.entryMode import com.google.android.fhir.datacapture.extensions.extractAnswerOptions @@ -50,6 +51,7 @@ import com.google.android.fhir.datacapture.extensions.zipByLinkId import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateExpression import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated @@ -65,7 +67,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.Element import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent @@ -317,7 +321,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem) - updateDependentQuestionnaireResponseItems(questionnaireItem) + updateDependentQuestionnaireResponseItems(questionnaireItem, questionnaireResponseItem) modificationCount.update { it + 1 } } @@ -439,17 +443,22 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat getQuestionnaireState() .also { detectExpressionCyclicDependency(questionnaire.item) } .also { - questionnaire.item.flattened().forEach { - updateDependentQuestionnaireResponseItems(it) + questionnaire.item.flattened().forEach { qItem -> + updateDependentQuestionnaireResponseItems( + qItem, + questionnaireResponse.allItems.find { it.linkId == qItem.linkId } + ) } } ) private fun updateDependentQuestionnaireResponseItems( updatedQuestionnaireItem: QuestionnaireItemComponent, + updatedQuestionnaireResponseItem: QuestionnaireResponseItemComponent?, ) { evaluateCalculatedExpressions( updatedQuestionnaireItem, + updatedQuestionnaireResponseItem, questionnaire, questionnaireResponse, questionnaireItemParentMap @@ -534,6 +543,26 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat return options } + private fun resolveCqfExpression( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + element: Element, + ): List { + val cqfExpression = element.cqfExpression ?: return emptyList() + + if (!cqfExpression.isFhirPath) { + throw UnsupportedOperationException("${cqfExpression.language} not supported yet") + } + return evaluateExpression( + questionnaire, + questionnaireResponse, + questionnaireItem, + questionnaireResponseItem, + cqfExpression, + questionnaireItemParentMap + ) + } + private suspend fun loadAnswerExpressionOptions( item: QuestionnaireItemComponent, expression: Expression, @@ -675,6 +704,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } else { NotValidated } + + // Set question text dynamically from CQL expression + questionnaireResponseItem.apply { + resolveCqfExpression(questionnaireItem, this, questionnaireItem.textElement) + .firstOrNull() + ?.let { text = it.primitiveValue() } + } + val items = buildList { // Add an item for the question itself add( @@ -775,7 +812,9 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } .map { (questionnaireItem, questionnaireResponseItem) -> questionnaireResponseItem.apply { - text = questionnaireItem.localizedTextSpanned?.toString() + if (text.isNullOrBlank()) { + text = questionnaireItem.localizedTextSpanned?.toString() + } // Nested group items item = getEnabledResponseItems(questionnaireItem.item, questionnaireResponseItem.item) // Nested question items diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreElements.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreElements.kt new file mode 100644 index 0000000000..f7f944078f --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreElements.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.extensions + +import org.hl7.fhir.r4.model.Element +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.utils.ToolingExtensions + +internal const val EXTENSION_CQF_EXPRESSION_URL: String = + "http://hl7.org/fhir/StructureDefinition/cqf-expression" + +internal val Element.cqfExpression: Expression? + get() = + ToolingExtensions.getExtension(this, EXTENSION_CQF_EXPRESSION_URL)?.value?.let { + it.castToExpression(it) + } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt index 2a5deb5e20..466e7727d9 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -25,7 +25,9 @@ import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.Type import timber.log.Timber @@ -81,16 +83,52 @@ object ExpressionEvaluator { } } + /** + * Returns the evaluation result of the expression. + * + * FHIRPath supplements are handled according to + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. + * + * %resource = [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] + */ + fun evaluateExpression( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent?, + expression: Expression, + questionnaireItemParentMap: Map + ): List { + val appContext = + mutableMapOf().apply { + extractDependentVariables( + expression, + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireItem, + this + ) + } + return fhirPathEngine.evaluate( + appContext, + questionnaireResponse, + null, + questionnaireResponseItem, + expression.expression + ) + } + /** * Returns a list of pair of item and the calculated and evaluated value for all items with * calculated expression extension, which is dependent on value of updated response */ fun evaluateCalculatedExpressions( - updatedQuestionnaireItem: Questionnaire.QuestionnaireItemComponent, + updatedQuestionnaireItem: QuestionnaireItemComponent, + updatedQuestionnaireResponseItemComponent: QuestionnaireResponseItemComponent?, questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse, - questionnaireItemParentMap: - Map + questionnaireItemParentMap: Map ): List { return questionnaire.item .flattened() @@ -102,26 +140,14 @@ object ExpressionEvaluator { findDependentVariables(item.calculatedExpression!!).isNotEmpty()) } .map { questionnaireItem -> - val appContext = - mutableMapOf().apply { - extractDependentVariables( - questionnaireItem.calculatedExpression!!, + val updatedAnswer = + evaluateExpression( questionnaire, questionnaireResponse, - questionnaireItemParentMap, questionnaireItem, - this - ) - } - - val updatedAnswer = - fhirPathEngine - .evaluate( - appContext, - questionnaireResponse, - null, - null, - questionnaireItem.calculatedExpression!!.expression + updatedQuestionnaireResponseItemComponent, + questionnaireItem.calculatedExpression!!, + questionnaireItemParentMap ) .map { it.castToType(it) } questionnaireItem to updatedAnswer diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt index 23c1aa06d0..e974850799 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt @@ -26,7 +26,6 @@ import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility import com.google.android.fhir.datacapture.extensions.initHelpViews import com.google.android.fhir.datacapture.extensions.localizedInstructionsSpanned import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned -import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility internal class GroupHeaderView(context: Context, attrs: AttributeSet?) : @@ -48,7 +47,8 @@ internal class GroupHeaderView(context: Context, attrs: AttributeSet?) : questionnaireItem = questionnaireViewItem.questionnaireItem ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - question.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedTextSpanned) + // CQF expression takes precedence over static question text + question.updateTextAndVisibility(questionnaireViewItem.questionText) hint.updateTextAndVisibility( questionnaireViewItem.enabledDisplayItems.localizedInstructionsSpanned ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt index e66008eb23..6344a6bbf4 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt @@ -26,7 +26,6 @@ import com.google.android.fhir.datacapture.extensions.getHeaderViewVisibility import com.google.android.fhir.datacapture.extensions.initHelpViews import com.google.android.fhir.datacapture.extensions.localizedInstructionsSpanned import com.google.android.fhir.datacapture.extensions.localizedPrefixSpanned -import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.updateTextAndVisibility /** View for the prefix, question, and hint of a questionnaire item. */ @@ -49,7 +48,8 @@ internal class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout questionnaireItem = questionnaireViewItem.questionnaireItem ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - question.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedTextSpanned) + // CQF expression takes precedence over static question text + question.updateTextAndVisibility(questionnaireViewItem.questionText) hint.updateTextAndVisibility( questionnaireViewItem.enabledDisplayItems.localizedInstructionsSpanned ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index 8a2d284fc3..953f10018f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -17,10 +17,13 @@ package com.google.android.fhir.datacapture.views import android.content.Context +import android.text.Spanned +import androidx.core.text.toSpanned import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.answerExpression import com.google.android.fhir.datacapture.extensions.displayString +import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult @@ -199,6 +202,15 @@ data class QuestionnaireViewItem( } } + /** + * Fetches the question title that should be displayed to user. The title is first fetched from + * [Questionnaire.QuestionnaireResponseItemComponent] (derived from cqf-expression), otherwise it + * is derived from [localizedTextSpanned] of [QuestionnaireResponse.QuestionnaireItemComponent] + */ + internal val questionText: Spanned? by lazy { + questionnaireResponseItem.text?.toSpanned() ?: questionnaireItem.localizedTextSpanned + } + /** * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the * same [Questionnaire.QuestionnaireItemComponent] and @@ -214,12 +226,12 @@ data class QuestionnaireViewItem( /** * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the - * same answers. + * same response. * - * This is useful for determining if the [QuestionnaireViewItem] has outdated answer(s) and - * therefore needs to be updated in the [RecyclerView] UI. + * This is useful for determining if the [QuestionnaireViewItem] has outdated answer(s) or + * question text and therefore needs to be updated in the [RecyclerView] UI. */ - internal fun hasTheSameAnswer(other: QuestionnaireViewItem) = + internal fun hasTheSameResponse(other: QuestionnaireViewItem) = answers.size == other.answers.size && answers .zip(other.answers) { answer, otherAnswer -> @@ -228,7 +240,8 @@ data class QuestionnaireViewItem( answer.value.equalsShallow(otherAnswer.value) } .all { it } && - draftAnswer == other.draftAnswer + draftAnswer == other.draftAnswer && + questionText == other.questionText /** * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the 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 73b43b140f..e9626d5d25 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 @@ -34,6 +34,7 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_SUBMIT_BUTTON import com.google.android.fhir.datacapture.extensions.DisplayItemControlType import com.google.android.fhir.datacapture.extensions.EXTENSION_CALCULATED_EXPRESSION_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_EXPRESSION_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_ENABLE_WHEN_EXPRESSION_URL @@ -41,6 +42,7 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ENTRY_MODE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_HIDDEN_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_VARIABLE_URL import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.INSTRUCTIONS import com.google.android.fhir.datacapture.extensions.asStringValue @@ -59,6 +61,7 @@ import java.util.UUID import kotlin.test.assertFailsWith import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -77,11 +80,13 @@ import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.HumanName +import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Practitioner import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.ValueSet import org.hl7.fhir.r4.utils.ToolingExtensions @@ -4738,6 +4743,141 @@ class QuestionnaireViewModelTest { assertThat(enabledDisplayItems[0].text).isEqualTo("Text when yes is selected") } + @Test + fun `should update question title for questionnaire item with cqf expression extension when corresponding item answer is updated`() = + runTest { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age" + type = Questionnaire.QuestionnaireItemType.INTEGER + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-description" + type = Questionnaire.QuestionnaireItemType.STRING + textElement.addExtension().apply { + url = EXTENSION_CQF_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age' and answer.empty().not()).select('Notes for child of age ' + answer.value.toString() + ' years')" + } + ) + } + } + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + + val ageResponseItem = viewModel.getQuestionnaireResponse().item.first { it.linkId == "a-age" } + + assertThat(ageResponseItem.answer).isEmpty() + + var descriptionResponseItem: QuestionnaireViewItem? = null + + val job = + this.launch { + viewModel.questionnaireStateFlow.collect { + descriptionResponseItem = + it.items + .find { it.asQuestion().questionnaireItem.linkId == "a-description" }!!.asQuestion() + this@launch.cancel() + } + } + job.join() + + assertThat(descriptionResponseItem!!.questionText).isNull() + val ageItemUpdated = + viewModel.questionnaireStateFlow.value.items + .first { it.asQuestionOrNull()?.questionnaireItem?.linkId == "a-age" } + .asQuestion() + .apply { + this.answersChangedCallback( + this.questionnaireItem, + this.getQuestionnaireResponseItem(), + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = IntegerType(2) + } + ), + null + ) + } + + assertThat( + ageItemUpdated.getQuestionnaireResponseItem().answer.first().valueIntegerType.value + ) + .isEqualTo(2) + + val descriptionItemUpdated = + viewModel.questionnaireStateFlow.value.items + .first { it.asQuestionOrNull()?.questionnaireItem?.linkId == "a-description" } + .asQuestion() + + assertThat(descriptionItemUpdated.questionText.toString()) + .isEqualTo("Notes for child of age 2 years") + } + + @Test + fun `should update question title for questionnaire item with cqf expression extension when expression has variable`() = + runTest { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "1" + } + ) + } + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-description" + type = Questionnaire.QuestionnaireItemType.STRING + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "B" + language = "text/fhirpath" + expression = "2" + } + ) + } + textElement.addExtension().apply { + url = EXTENSION_CQF_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = "'Sum of variables is ' + ( %A + %B ).toString() " + } + ) + } + } + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + + val descriptionItem = + viewModel + .getQuestionnaireItemViewItemList() + .first { it.asQuestionOrNull()?.questionnaireItem?.linkId == "a-description" } + .asQuestion() + + assertThat(descriptionItem.questionText.toString()).isEqualTo("Sum of variables is 3") + } + private fun createQuestionnaireViewModel( questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse? = null, diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt index f526109255..ba4946e5a6 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt @@ -539,6 +539,7 @@ class ExpressionEvaluatorTest { val result = evaluateCalculatedExpressions( questionnaire.item.elementAt(1), + questionnaireResponse.item.elementAt(1), questionnaire, questionnaireResponse, emptyMap() @@ -611,6 +612,7 @@ class ExpressionEvaluatorTest { val result = evaluateCalculatedExpressions( questionnaire.item.elementAt(1), + questionnaireResponse.item.elementAt(1), questionnaire, questionnaireResponse, emptyMap() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/GroupHeaderViewTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/GroupHeaderViewTest.kt index 7dfbdf0cf8..103ba2a5a8 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/GroupHeaderViewTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/GroupHeaderViewTest.kt @@ -36,6 +36,8 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.utils.ToolingExtensions import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -50,11 +52,13 @@ class GroupHeaderViewTest { private val view = GroupHeaderView(parent.context, null) private fun getQuestionnaireViewItemWithQuestionnaireItem( - questionnaireItem: Questionnaire.QuestionnaireItemComponent + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent = + QuestionnaireResponse.QuestionnaireResponseItemComponent() ): QuestionnaireViewItem { return QuestionnaireViewItem( questionnaireItem = questionnaireItem, - questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + questionnaireResponseItem = questionnaireResponseItem, validationResult = Valid, answersChangedCallback = { _, _, _, _ -> } ) @@ -110,6 +114,39 @@ class GroupHeaderViewTest { assertThat(view.findViewById(R.id.question).text.toString()).isEqualTo("Question?") } + @Test + fun `should show question from localized text`() { + view.bind( + getQuestionnaireViewItemWithQuestionnaireItem( + Questionnaire.QuestionnaireItemComponent().apply { + repeats = true + textElement.apply { + addExtension( + Extension(ToolingExtensions.EXT_TRANSLATION).apply { + addExtension(Extension("lang", StringType("en"))) + addExtension(Extension("content", StringType("Question?"))) + } + ) + } + } + ) + ) + + assertThat(view.findViewById(R.id.question).text.toString()).isEqualTo("Question?") + } + + @Test + fun `should show question from questionnaire response item derived text`() { + view.bind( + getQuestionnaireViewItemWithQuestionnaireItem( + Questionnaire.QuestionnaireItemComponent().apply { repeats = true }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { text = "Question?" } + ) + ) + + assertThat(view.findViewById(R.id.question).text.toString()).isEqualTo("Question?") + } + @Test fun `shows instructions`() { val itemList = diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/HeaderViewTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/HeaderViewTest.kt index bb6fa9e509..2bcc186755 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/HeaderViewTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/HeaderViewTest.kt @@ -36,6 +36,8 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.StringType +import org.hl7.fhir.r4.utils.ToolingExtensions import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -50,11 +52,13 @@ class HeaderViewTest { private val view = HeaderView(parent.context, null) private fun getQuestionnaireViewItemWithQuestionnaireItem( - questionnaireItem: Questionnaire.QuestionnaireItemComponent + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent = + QuestionnaireResponse.QuestionnaireResponseItemComponent() ): QuestionnaireViewItem { return QuestionnaireViewItem( questionnaireItem = questionnaireItem, - questionnaireResponseItem = QuestionnaireResponse.QuestionnaireResponseItemComponent(), + questionnaireResponseItem = questionnaireResponseItem, validationResult = Valid, answersChangedCallback = { _, _, _, _ -> } ) @@ -110,6 +114,39 @@ class HeaderViewTest { assertThat(view.findViewById(R.id.question).text.toString()).isEqualTo("Question?") } + @Test + fun `should show question from localized text`() { + view.bind( + getQuestionnaireViewItemWithQuestionnaireItem( + Questionnaire.QuestionnaireItemComponent().apply { + repeats = true + textElement.apply { + addExtension( + Extension(ToolingExtensions.EXT_TRANSLATION).apply { + addExtension(Extension("lang", StringType("en"))) + addExtension(Extension("content", StringType("Question?"))) + } + ) + } + } + ) + ) + + assertThat(view.findViewById(R.id.question).text.toString()).isEqualTo("Question?") + } + + @Test + fun `should show question from questionnaire response item derived text`() { + view.bind( + getQuestionnaireViewItemWithQuestionnaireItem( + Questionnaire.QuestionnaireItemComponent().apply { repeats = true }, + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { text = "Question?" } + ) + ) + + assertThat(view.findViewById(R.id.question).text.toString()).isEqualTo("Question?") + } + @Test fun `shows instructions`() { val itemList = diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItemTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItemTest.kt index e5bded6d02..4f42d0dec3 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItemTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItemTest.kt @@ -297,7 +297,7 @@ class QuestionnaireViewItemTest { } @Test - fun `hasTheSameAnswer() should return false for different answer list sizes`() { + fun `hasTheSameResponse() should return false for different answer list sizes`() { assertThat( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -311,7 +311,7 @@ class QuestionnaireViewItemTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> } ) - .hasTheSameAnswer( + .hasTheSameResponse( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent(), @@ -324,7 +324,7 @@ class QuestionnaireViewItemTest { } @Test - fun `hasTheSameAnswer() should return true for two empty answer lists`() { + fun `hasTheSameResponse() should return true for two empty answer lists`() { assertThat( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -332,7 +332,7 @@ class QuestionnaireViewItemTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> } ) - .hasTheSameAnswer( + .hasTheSameResponse( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent(), @@ -345,7 +345,7 @@ class QuestionnaireViewItemTest { } @Test - fun `hasTheSameAnswer() should return false for different answers`() { + fun `hasTheSameResponse() should return false for different answers`() { assertThat( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -359,7 +359,7 @@ class QuestionnaireViewItemTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> } ) - .hasTheSameAnswer( + .hasTheSameResponse( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -378,7 +378,7 @@ class QuestionnaireViewItemTest { } @Test - fun `hasTheSameAnswer() should return false for null and non-null answers`() { + fun `hasTheSameResponse() should return false for null and non-null answers`() { assertThat( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -387,7 +387,7 @@ class QuestionnaireViewItemTest { answersChangedCallback = { _, _, _, _ -> } ) .apply { setAnswer(QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent()) } - .hasTheSameAnswer( + .hasTheSameResponse( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { @@ -406,7 +406,7 @@ class QuestionnaireViewItemTest { } @Test - fun `hasTheSameAnswer() should return false for non-null and null answers`() { + fun `hasTheSameResponse() should return false for non-null and null answers`() { assertThat( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -420,7 +420,7 @@ class QuestionnaireViewItemTest { validationResult = NotValidated, answersChangedCallback = { _, _, _, _ -> } ) - .hasTheSameAnswer( + .hasTheSameResponse( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent(), @@ -434,7 +434,49 @@ class QuestionnaireViewItemTest { } @Test - fun `hasTheSameAnswer() should return true for the same answers`() { + fun `hasTheSameResponse() should return true for the same question text`() { + assertThat( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> } + ) + .hasTheSameResponse( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> } + ) + ) + ) + .isTrue() + } + + @Test + fun `hasTheSameResponse() should return false for the different question text`() { + assertThat( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question 1?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> } + ) + .hasTheSameResponse( + QuestionnaireViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question 2?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _, _ -> } + ) + ) + ) + .isFalse() + } + + @Test + fun `hasTheSameResponse() should return true for the same answers`() { assertThat( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), @@ -449,7 +491,7 @@ class QuestionnaireViewItemTest { } ) } - .hasTheSameAnswer( + .hasTheSameResponse( QuestionnaireViewItem( Questionnaire.QuestionnaireItemComponent(), QuestionnaireResponse.QuestionnaireResponseItemComponent(),