Skip to content

Commit

Permalink
Implement calculated-expression extension (#1380)
Browse files Browse the repository at this point in the history
* Implement calculated-expression extension

* Fix form value update bug

* Detect cyclic dependency | fix on init value loading

* Fix merge conflict

* Make birthdate age dependent | Handle and fix quantity values

* Fix failing test

* quantity viewholder delegate test covergae

* Test coverage for update flow

* spotless fix

* spotless fix | re-run ci

* Test covergae for questionnaire fragment

* test coverage for quantity types

* questionnaire fragment test with launchInFragmentContainer

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

Co-authored-by: aditya-07 <[email protected]>

* Move widget to LayoutList | Run Calculation after state-change

* Revert the run-expression after state-flow

* remove empty line changes

* spotless fix

* Esperesso test | Fix failing test

* Remove unnessary changes

* Fix espresso tests

* Ignore Failing tests

* Revert ignore test | merge main | refactor

* spotless fix

* Rename tests

* Add tests and docs

* Move catalog calculation to behavior tab

* spotless fix

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

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

* Resolve feedback for naming

* Resolve feedback for doc and naming

* Refactor the update answer handling logic

* Resolve feedback and merge master

* Fix failing test

Co-authored-by: aditya-07 <[email protected]>
Co-authored-by: Benjamin Mwalimu <[email protected]>
Co-authored-by: Jing Tang <[email protected]>
  • Loading branch information
4 people authored Oct 12, 2022
1 parent da1b5b3 commit 08357ef
Show file tree
Hide file tree
Showing 21 changed files with 1,513 additions and 300 deletions.
36 changes: 36 additions & 0 deletions catalog/src/main/assets/calculated_expression_questionnaire.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"resourceType": "Questionnaire",
"item": [
{
"linkId": "a-birthdate",
"text": "Birth Date",
"type": "date",
"extension": [
{
"url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression",
"valueExpression": {
"language": "text/fhirpath",
"expression": "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)"
}
}
]
},
{
"linkId": "a-age-years",
"text": "Age years",
"type": "quantity",
"initial": [{
"valueQuantity": {
"unit": "years",
"system": "http://unitsofmeasure.org",
"code": "years"
}
}]
},
{
"linkId": "a-age-acknowledge",
"text": "Input age to automatically calculate birthdate until birthdate is updated manually",
"type": "display"
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ class BehaviorListViewModel(application: Application) : AndroidViewModel(applica
val questionnaireFileName: String,
val workFlow: WorkflowType = WorkflowType.DEFAULT
) {
CALCULATIONS(R.drawable.ic_calculations_behavior, R.string.behavior_name_calculation, ""),
CALCULATED_EXPRESSION(
R.drawable.ic_calculations_behavior,
R.string.behavior_name_calculated_expression,
"calculated_expression_questionnaire.json"
),
SKIP_LOGIC(R.drawable.ic_skiplogic_behavior, R.string.behavior_name_skip_logic, "")
}
}
4 changes: 3 additions & 1 deletion catalog/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@
<string name="layout_name_paginated">Paginated</string>
<string name="layout_name_review">Review</string>
<string name="layout_name_read_only">Read only</string>
<string name="behavior_name_calculation">Calculation</string>
<string name="behavior_name_skip_logic">Skip logic</string>
<string
name="behavior_name_calculated_expression"
>Calculated Expression</string>
<string name="toolbar_text">Structured data capture \n Catalog</string>
<string
name="questionnaire_response_title"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* 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.views

import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.platform.app.InstrumentationRegistry
import com.google.android.fhir.datacapture.R
import com.google.android.fhir.datacapture.TestActivity
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.common.truth.Truth.assertThat
import java.math.BigDecimal
import org.hl7.fhir.r4.model.Quantity
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest {
@Rule
@JvmField
var activityScenarioRule: ActivityScenarioRule<TestActivity> =
ActivityScenarioRule(TestActivity::class.java)

private lateinit var parent: FrameLayout
private lateinit var viewHolder: QuestionnaireItemViewHolder

@Before
fun setup() {
activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) }
viewHolder = QuestionnaireItemEditTextQuantityViewHolderFactory.create(parent)
setTestLayout(viewHolder.itemView)
}

@Test
fun getValue_WithInitial_shouldReturn_Quantity_With_UnitAndSystem() {
val questionnaireItemViewItem =
QuestionnaireItemViewItem(
Questionnaire.QuestionnaireItemComponent().apply {
required = true
addInitial(
Questionnaire.QuestionnaireItemInitialComponent(
Quantity().apply {
code = "months"
system = "http://unitofmeasure.com"
}
)
)
},
QuestionnaireResponse.QuestionnaireResponseItemComponent(),
validationResult = NotValidated,
answersChangedCallback = { _, _, _ -> },
)
runOnUI { viewHolder.bind(questionnaireItemViewItem) }

onView(withId(R.id.text_input_edit_text)).perform(click())
onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
assertThat(
viewHolder.itemView.findViewById<TextView>(R.id.text_input_edit_text).text.toString()
)
.isEqualTo("22")

val responseValue = questionnaireItemViewItem.answers.first().valueQuantity
assertThat(responseValue.code).isEqualTo("months")
assertThat(responseValue.system).isEqualTo("http://unitofmeasure.com")
assertThat(responseValue.value).isEqualTo(BigDecimal(22))
}

@Test
fun getValue_WithoutInitial_shouldReturn_Quantity_Without_UnitAndSystem() {
val questionnaireItemViewItem =
QuestionnaireItemViewItem(
Questionnaire.QuestionnaireItemComponent().apply { required = true },
QuestionnaireResponse.QuestionnaireResponseItemComponent(),
validationResult = NotValidated,
answersChangedCallback = { _, _, _ -> },
)
runOnUI { viewHolder.bind(questionnaireItemViewItem) }

onView(withId(R.id.text_input_edit_text)).perform(click())
onView(withId(R.id.text_input_edit_text)).perform(typeText("22"))
assertThat(
viewHolder.itemView.findViewById<TextView>(R.id.text_input_edit_text).text.toString()
)
.isEqualTo("22")

val responseValue = questionnaireItemViewItem.answers.first().valueQuantity
assertThat(responseValue.code).isNull()
assertThat(responseValue.system).isNull()
assertThat(responseValue.value).isEqualTo(BigDecimal(22))
}

/** Method to run code snippet on UI/main thread */
private fun runOnUI(action: () -> Unit) {
activityScenarioRule.scenario.onActivity { action() }
}

/** Method to set content view for test activity */
private fun setTestLayout(view: View) {
activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) }
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ internal const val EXTENSION_ITEM_CONTROL_SYSTEM = "http://hl7.org/fhir/question
internal const val EXTENSION_HIDDEN_URL =
"http://hl7.org/fhir/StructureDefinition/questionnaire-hidden"

internal const val EXTENSION_CALCULATED_EXPRESSION_URL =
"http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression"

internal const val EXTENSION_ENTRY_FORMAT_URL =
"http://hl7.org/fhir/StructureDefinition/entryFormat"

Expand All @@ -83,6 +86,9 @@ internal const val EXTENSION_CHOICE_COLUMN_URL: String =

internal const val EXTENSION_VARIABLE_URL = "http://hl7.org/fhir/StructureDefinition/variable"

internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String =
"http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue"

internal const val EXTENSION_SLIDER_STEP_VALUE_URL =
"http://hl7.org/fhir/StructureDefinition/questionnaire-sliderStepValue"

Expand All @@ -104,8 +110,32 @@ internal fun Questionnaire.QuestionnaireItemComponent.findVariableExpression(
return variableExpressions.find { it.name == variableName }
}

internal const val CQF_CALCULATED_EXPRESSION_URL: String =
"http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue"
/** Returns Calculated expression, or null */
internal val Questionnaire.QuestionnaireItemComponent.calculatedExpression: Expression?
get() =
this.getExtensionByUrl(EXTENSION_CALCULATED_EXPRESSION_URL)?.let {
it.castToExpression(it.value)
}

/** Returns list of extensions whose value is of type [Expression] */
internal val Questionnaire.QuestionnaireItemComponent.expressionBasedExtensions
get() = this.extension.filter { it.value is Expression }

/**
* Whether [item] has any expression directly referencing the current questionnaire item by link ID
* (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where
* `this-question` is the link ID of the current questionnaire item).
*/
internal fun Questionnaire.QuestionnaireItemComponent.isReferencedBy(
item: Questionnaire.QuestionnaireItemComponent
) =
item.expressionBasedExtensions.any {
it
.castToExpression(it.value)
.expression
.replace(" ", "")
.contains(Regex(".*linkId='${this.linkId}'.*"))
}

// Item control code, or null
internal val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTypes?
Expand Down Expand Up @@ -358,7 +388,12 @@ val Questionnaire.QuestionnaireItemComponent.enableWhenExpression: Expression?
*/
private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers():
MutableList<QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent>? {
if (initial.isEmpty()) {
// https://build.fhir.org/ig/HL7/sdc/behavior.html#initial
// quantity given as initial without value is for unit reference purpose only. Answer conversion
// not needed
if (initial.isEmpty() ||
(initialFirstRep.hasValueQuantity() && initialFirstRep.valueQuantity.value == null)
) {
return null
}

Expand Down Expand Up @@ -481,6 +516,15 @@ internal fun Questionnaire.QuestionnaireItemComponent.extractAnswerOptions(
}.map { Questionnaire.QuestionnaireItemAnswerOptionComponent(it) }
}

/**
* Flatten a nested list of [Questionnaire.QuestionnaireItemComponent] recursively and returns a
* flat list of all items into list embedded at any level
*/
fun List<Questionnaire.QuestionnaireItemComponent>.flattened():
List<Questionnaire.QuestionnaireItemComponent> {
return this + this.flatMap { it.item.flattened() }
}

/**
* Creates a list of [QuestionnaireResponse.QuestionnaireResponseItemComponent]s from the nested
* items in the [Questionnaire.QuestionnaireItemComponent].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import org.hl7.fhir.r4.model.Quantity
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.StringType
import org.hl7.fhir.r4.model.TimeType
import org.hl7.fhir.r4.model.Type
import org.hl7.fhir.r4.model.UriType

/**
Expand Down Expand Up @@ -73,3 +74,8 @@ internal fun QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent.disp
else -> context.getString(R.string.not_answered)
}
}

internal fun List<QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent>
.hasDifferentAnswerSet(answers: List<Type>) =
this.size != answers.size ||
this.map { it.value }.zip(answers).any { (v1, v2) -> v1.equalsDeep(v2).not() }
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import ca.uhn.fhir.context.FhirVersionEnum
import ca.uhn.fhir.parser.IParser
import com.google.android.fhir.FhirEngineProvider
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions
import com.google.android.fhir.datacapture.utilities.fhirPathEngine
import com.google.android.fhir.datacapture.validation.NotValidated
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseItemValidator
Expand Down Expand Up @@ -274,8 +276,10 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
if (questionnaireItem.hasNestedItemsWithinAnswers) {
questionnaireResponseItem.addNestedItemsToAnswer(questionnaireItem)
}

modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem)

updateDependentQuestionnaireResponseItems(questionnaireItem)

modificationCount.update { it + 1 }
}

Expand Down Expand Up @@ -377,12 +381,50 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
SharingStarted.Lazily,
initialValue =
getQuestionnaireState(
questionnaireItemList = questionnaire.item,
questionnaireResponseItemList = questionnaireResponse.item,
currentPageIndex = getInitialPageIndex(),
reviewMode = enableReviewPage
)
questionnaireItemList = questionnaire.item,
questionnaireResponseItemList = questionnaireResponse.item,
currentPageIndex = getInitialPageIndex(),
reviewMode = enableReviewPage
)
.also { detectExpressionCyclicDependency(questionnaire.item) }
.also {
questionnaire.item.flattened().forEach {
updateDependentQuestionnaireResponseItems(it)
}
}
)

fun updateDependentQuestionnaireResponseItems(
updatedQuestionnaireItem: Questionnaire.QuestionnaireItemComponent
) {
evaluateCalculatedExpressions(
updatedQuestionnaireItem,
questionnaire,
questionnaireResponse,
questionnaireItemParentMap
)
.forEach { (questionnaireItem, calculatedAnswers) ->
// update all response item with updated values
questionnaireResponseItemPreOrderList
// Item answer should not be modified and touched by user;
// https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-calculatedExpression.html
.filter {
it.linkId == questionnaireItem.linkId &&
!modifiedQuestionnaireResponseItemSet.contains(it)
}
.forEach { questionnaireResponseItem ->
// update and notify only if new answer has changed to prevent any event loop
if (questionnaireResponseItem.answer.hasDifferentAnswerSet(calculatedAnswers)) {
questionnaireResponseItem.answer =
calculatedAnswers.map {
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
value = it
}
}
}
}
}
}

@PublishedApi
internal suspend fun resolveAnswerValueSet(
Expand Down
Loading

0 comments on commit 08357ef

Please sign in to comment.