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(),