Skip to content

Commit

Permalink
Cqf expression implementation for dynamic question title (#1959)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt

Co-authored-by: santosh-pingle <[email protected]>

* Update catalog/src/main/res/values/strings.xml

Co-authored-by: Jing Tang <[email protected]>

* Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt

Co-authored-by: Jing Tang <[email protected]>

* 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 <[email protected]>

* Update datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt

Co-authored-by: Jing Tang <[email protected]>

* Update datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt

Co-authored-by: Jing Tang <[email protected]>

* Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt

Co-authored-by: Jing Tang <[email protected]>

* Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt

Co-authored-by: Jing Tang <[email protected]>

* Move code to MoreElements

* Make expression eval generic method

* Add android test for cqf

---------

Co-authored-by: Benjamin Mwalimu <[email protected]>
Co-authored-by: santosh-pingle <[email protected]>
Co-authored-by: Jing Tang <[email protected]>
  • Loading branch information
4 people committed May 17, 2023
1 parent ab256e0 commit 32939ce
Show file tree
Hide file tree
Showing 18 changed files with 602 additions and 50 deletions.
68 changes: 68 additions & 0 deletions catalog/src/main/assets/behavior_dynamic_question_text.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
{
"resourceType": "Questionnaire",
"item": [
{
"text": "Choose an option below",
"type": "choice",
"extension": [
{
"url": "http:https://hl7.org/fhir/StructureDefinition/questionnaire-itemControl",
"valueCodeableConcept": {
"coding": [
{
"system": "http:https://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:https://hl7.org/fhir/StructureDefinition/cqf-expression",
"valueExpression": {
"language": "text/fhirpath",
"expression": "'Provide \"'+ %resource.descendants().where(linkId = '1').answer.value.display + '\" Date'"
}
}
]
},
"type": "date"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
}
}
30 changes: 30 additions & 0 deletions catalog/src/main/res/drawable/ic_dynamic_text_behavior.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<vector
xmlns:android="http:https://schemas.android.com/apk/res/android"
android:width="194dp"
android:height="128dp"
android:viewportWidth="194"
android:viewportHeight="128"
>
<path
android:strokeWidth="1"
android:pathData="M135.5,27V101C135.5,101.828 134.828,102.5 134,102.5H60C59.172,102.5 58.5,101.828 58.5,101V27C58.5,26.172 59.172,25.5 60,25.5H97H134C134.828,25.5 135.5,26.172 135.5,27Z"
android:fillColor="#ffffff"
android:strokeColor="#8AB4F8"
/>
<path
android:pathData="M64,49C64,43.477 68.477,39 74,39L135,39V59L74,59C68.477,59 64,54.523 64,49V49Z"
android:fillColor="#D2E3FC"
/>
<path
android:pathData="M74,49m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:fillColor="#1A73E8"
/>
<path
android:pathData="M125,79C125,84.523 120.523,89 115,89L59,89L59,69L115,69C120.523,69 125,73.477 125,79V79Z"
android:fillColor="#D2E3FC"
/>
<path
android:pathData="M115,79m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:fillColor="#1A73E8"
/>
</vector>
3 changes: 3 additions & 0 deletions catalog/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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.</string>
<string name="behavior_name_context_variables">Context variable</string>
<string
name="behavior_name_dynamic_question_text"
>Dynamic question text</string>
<string
name="behavior_name_calculated_expression_info"
>Input age to automatically calculate birthdate until birthdate is updated manually.</string>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"resourceType": "Questionnaire",
"item": [
{
"text": "Choose an option below",
"type": "choice",
"extension": [
{
"url": "http:https://hl7.org/fhir/StructureDefinition/questionnaire-itemControl",
"valueCodeableConcept": {
"coding": [
{
"system": "http:https://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:https://hl7.org/fhir/StructureDefinition/cqf-expression",
"valueExpression": {
"language": "text/fhirpath",
"expression": "'Provide \"'+ %resource.descendants().where(linkId = '1').answer.value.display + '\" Date'"
}
}
]
},
"type": "date"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -317,7 +321,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem)

updateDependentQuestionnaireResponseItems(questionnaireItem)
updateDependentQuestionnaireResponseItems(questionnaireItem, questionnaireResponseItem)

modificationCount.update { it + 1 }
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -534,6 +543,26 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
return options
}

private fun resolveCqfExpression(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
element: Element,
): List<Base> {
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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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:https://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:https://hl7.org/fhir/StructureDefinition/cqf-expression"

internal val Element.cqfExpression: Expression?
get() =
ToolingExtensions.getExtension(this, EXTENSION_CQF_EXPRESSION_URL)?.value?.let {
it.castToExpression(it)
}
Loading

0 comments on commit 32939ce

Please sign in to comment.