From 9ab667219b620525e1dfaf37cf5305e93a8a237d Mon Sep 17 00:00:00 2001 From: maimoonak Date: Fri, 7 Apr 2023 15:25:00 +0500 Subject: [PATCH 01/18] CQF Expression implementation for question title --- .../behavior_dynamic_question_title.json | 68 +++++++++++ .../fhir/catalog/BehaviorListViewModel.kt | 5 + .../drawable/ic_dynamic_title_behavior.xml | 30 +++++ catalog/src/main/res/values/strings.xml | 3 + .../fhir/datacapture/QuestionnaireFragment.kt | 7 ++ .../datacapture/QuestionnaireViewModel.kt | 54 ++++++++- .../datacapture/extensions/MoreExpressions.kt | 11 ++ .../extensions/MoreQuestionnaires.kt | 59 ++++++++++ .../fhir/datacapture/extensions/MoreTypes.kt | 16 +++ .../fhirpath/ExpressionEvaluator.kt | 82 ++++++++++++-- .../fhir/datacapture/fhirpath/FhirPathUtil.kt | 25 ++++- .../datacapture/mapping/ResourceMapper.kt | 28 ++--- .../validation/MaxValueValidator.kt | 1 + .../validation/MinValueValidator.kt | 1 + .../fhir/datacapture/validation/MoreTypes.kt | 37 ------ .../fhir/datacapture/views/HeaderView.kt | 3 +- .../views/QuestionnaireViewItem.kt | 22 +++- .../datacapture/QuestionnaireViewModelTest.kt | 74 ++++++++++++ .../extensions/MoreQuestionnairesTest.kt | 58 ++++++++++ .../datacapture/extensions/MoreTypesTest.kt | 48 ++++++++ .../fhirpath/ExpressionEvaluatorTest.kt | 106 ++++++++++++++++++ .../validation/MaxValueValidatorTest.kt | 5 +- .../validation/MinValueValidatorTest.kt | 7 +- .../datacapture/validation/MoreTypesTest.kt | 68 ----------- .../android/fhir/demo/FhirApplication.kt | 3 + 25 files changed, 677 insertions(+), 144 deletions(-) create mode 100644 catalog/src/main/assets/behavior_dynamic_question_title.json create mode 100644 catalog/src/main/res/drawable/ic_dynamic_title_behavior.xml delete mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MoreTypes.kt delete mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MoreTypesTest.kt diff --git a/catalog/src/main/assets/behavior_dynamic_question_title.json b/catalog/src/main/assets/behavior_dynamic_question_title.json new file mode 100644 index 0000000000..a24fb3a936 --- /dev/null +++ b/catalog/src/main/assets/behavior_dynamic_question_title.json @@ -0,0 +1,68 @@ +{ + "resourceType": "Questionnaire", + "item": [ + { + "text": "Choose vaccination administered", + "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": "bcg", + "display": "BCG - Bacillus Calmette Guerin Vaccine" + } + }, + { + "valueCoding": { + "code": "opv 0", + "display": "OPV 0 - Oral Poliovirus Vaccine" + } + }, + { + "valueCoding": { + "code": "penta 1", + "display": "Penta 1 - Pentavalent (DPT + Hep B + Hib) Vaccine" + } + }, + { + "valueCoding": { + "code": "pcv 1", + "display": "PCV 1 - Pneumococcal Conjugate Vaccine" + } + } + ] + }, + { + "linkId": "2", + "required": true, + "text": "Vaccine Date", + "_text": { + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/cqf-expression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "'Select '+ %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 6e9379b475..dd1561d036 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 @@ -42,6 +42,11 @@ class BehaviorListViewModel(application: Application) : AndroidViewModel(applica R.drawable.ic_skiplogic_behavior, R.string.behavior_name_skip_logic, "behavior_skip_logic.json" + ), + DYNAMIC_QUESTION_TITLE( + R.drawable.ic_dynamic_title_behavior, + R.string.behavior_name_dynamic_question_title, + "behavior_dynamic_question_title.json" ) } } diff --git a/catalog/src/main/res/drawable/ic_dynamic_title_behavior.xml b/catalog/src/main/res/drawable/ic_dynamic_title_behavior.xml new file mode 100644 index 0000000000..e47e63e483 --- /dev/null +++ b/catalog/src/main/res/drawable/ic_dynamic_title_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 ae5175082d..604131dfea 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -43,6 +43,9 @@ If Yes is selected, a follow-up question is displayed. If No is selected, no follow-up questions are displayed. + Dynamic Question Title Input age to automatically calculate birthdate until birthdate is updated manually. diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt index 9e9301dfc2..30ed1f23b5 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt @@ -303,6 +303,10 @@ class QuestionnaireFragment : Fragment() { args.add(EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI to questionnaireResponseUri) } + fun setQuestionnaireResourceContext(questionnaireResourceContext: String) = apply { + args.add(EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING to questionnaireResourceContext) + } + /** * An [Boolean] extra to control if the questionnaire is read-only. If review page and read-only * are both enabled, read-only will take precedence. @@ -385,6 +389,9 @@ class QuestionnaireFragment : Fragment() { */ internal const val EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING = "questionnaire-response" + /** A JSON encoded string extra for questionnaire context. */ + internal const val EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING = + "questionnaire-launch-context" /** * A [URI][android.net.Uri] extra for streaming a JSON encoded questionnaire response. * 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 f6e8e2d6bd..08446e39df 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 @@ -26,10 +26,12 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.enablement.EnablementEvaluator +import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT 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 @@ -44,8 +46,11 @@ import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.packRepeatedGroups import com.google.android.fhir.datacapture.extensions.shouldHaveNestedItemsUnderAnswers import com.google.android.fhir.datacapture.extensions.unpackRepeatedGroups +import com.google.android.fhir.datacapture.extensions.validateLaunchContext +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.evaluateToBase import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated @@ -61,13 +66,16 @@ 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 import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.ValueSet import timber.log.Timber @@ -148,6 +156,32 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat questionnaireResponse.packRepeatedGroups() } + /** + * The launch context allows information to be passed into questionnaire based on the context in + * which he questionnaire is being evaluated. For example, what patient, what encounter, what + * user, etc. is "in context" at the time the questionnaire response is being completed. + * Currently, we support at most one launch context.The supported launch contexts are defined in: + * https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-launchContext.html + */ + private val questionnaireLaunchContext: Resource? + + init { + questionnaireLaunchContext = + if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING)) { + val questionnaireLaunchContextJson: String = + state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING]!! + questionnaire.extension + .firstOrNull { it.url == EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT } + ?.let { + val resource = parser.parseResource(questionnaireLaunchContextJson) as Resource + validateLaunchContext(it, resource.resourceType.name) + resource + } + } else { + null + } + } + /** The map from each item in the [Questionnaire] to its parent. */ private var questionnaireItemParentMap: Map @@ -496,6 +530,20 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat return options } + internal fun resolveCqfExpression( + questionnaireResponseItem: QuestionnaireResponseItemComponent, + element: Element, + ): List { + val cqfExpression = element.cqfExpression ?: return emptyList() + if (cqfExpression.isFhirPath) { + return evaluateToBase( + questionnaireResponse, + questionnaireResponseItem, + cqfExpression.expression + ) + } else throw UnsupportedOperationException("${cqfExpression.language} not supported yet") + } + private suspend fun loadAnswerExpressionOptions( item: QuestionnaireItemComponent, expression: Expression, @@ -505,7 +553,10 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat checkNotNull(xFhirQueryResolver) { "XFhirQueryResolver cannot be null. Please provide the XFhirQueryResolver via DataCaptureConfig." } - xFhirQueryResolver!!.resolve(expression.expression) + + val xFhirExpressionString = + ExpressionEvaluator.createXFhirQueryFromExpression(expression, questionnaireLaunchContext) + xFhirQueryResolver!!.resolve(xFhirExpressionString) } else if (expression.isFhirPath) { fhirPathEngine.evaluate(questionnaireResponse, expression.expression) } else { @@ -645,6 +696,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat answersChangedCallback = answersChangedCallback, resolveAnswerValueSet = { resolveAnswerValueSet(it) }, resolveAnswerExpression = { resolveAnswerExpression(it) }, + resolveCqfExpression = { resolveCqfExpression(questionnaireResponseItem, it) }, draftAnswer = draftAnswerMap[questionnaireResponseItem], enabledDisplayItems = questionnaireItem.item.filter { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt index 5c77c0d5a2..dc22d0698b 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt @@ -16,7 +16,18 @@ 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) + } internal val Expression.isXFhirQuery: Boolean get() = this.language == Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt index 6f6598215d..d0ba10474d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt @@ -17,7 +17,9 @@ package com.google.android.fhir.datacapture.extensions import org.hl7.fhir.r4.model.CanonicalType +import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire /** @@ -45,6 +47,58 @@ internal val Questionnaire.variableExpressions: List internal fun Questionnaire.findVariableExpression(variableName: String): Expression? = variableExpressions.find { it.name == variableName } +/** + * Validates the questionnaire launch context extension, if it exists, and well formed, and + * validates if the resource type is applicable as a launch context. + */ +internal fun validateLaunchContext(extension: Extension, resourceType: String) { + val nameExtension = + extension.extension + .firstOrNull { it.url == "name" } + ?.value.takeIf { type -> + type is Coding && + QuestionnaireLaunchContextSet.values().any { + it.code == type.code && it.display == type.display && it.system == type.system + } + } + + val typeExtension = + extension.extension + .firstOrNull { it.url == "type" } + ?.takeIf { it.valueAsPrimitive.valueAsString == resourceType } + + if (nameExtension == null) { + error( + "The value of the extension:name field in " + + "$EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT is not one of the ones defined in " + + "$EXTENSION_LAUNCH_CONTEXT." + ) + } + + if (typeExtension == null) { + error( + "The resource type set in the extension:type field in " + + "$EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT does not match the resource type of the " + + "context passed in: $resourceType." + ) + } +} + +/** + * The set of supported launch contexts, as per: http://hl7.org/fhir/uv/sdc/ValueSet/launchContext + */ +private enum class QuestionnaireLaunchContextSet( + val code: String, + val display: String, + val system: String, +) { + PATIENT("patient", "Patient", EXTENSION_LAUNCH_CONTEXT), + ENCOUNTER("encounter", "Encounter", EXTENSION_LAUNCH_CONTEXT), + LOCATION("location", "Location", EXTENSION_LAUNCH_CONTEXT), + USER("user", "User", EXTENSION_LAUNCH_CONTEXT), + STUDY("study", "ResearchStudy", EXTENSION_LAUNCH_CONTEXT), +} + /** * See * [Extension: target structure map](http://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-targetStructureMap.html) @@ -64,6 +118,11 @@ val Questionnaire.isPaginated: Boolean internal const val EXTENSION_ENTRY_MODE_URL: String = "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-entryMode" +internal const val EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext" + +internal const val EXTENSION_LAUNCH_CONTEXT = "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext" + val Questionnaire.entryMode: EntryMode? get() { val entryMode = diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index 1cf27ebc77..9ffc4eb54f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.extensions import android.content.Context import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine import com.google.android.fhir.datacapture.views.factories.localDate import com.google.android.fhir.datacapture.views.factories.localTime import com.google.android.fhir.getLocalizedText @@ -28,6 +29,7 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.PrimitiveType @@ -111,3 +113,17 @@ internal fun StringType.toIdType(): IdType { internal fun Coding.toCodeType(): CodeType { return CodeType(code) } + +fun Type.valueOrCalculateValue(): Type { + return if (this.hasExtension()) { + this.extension + .firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } + ?.let { extension -> + val expression = (extension.value as Expression).expression + fhirPathEngine.evaluate(this, expression).singleOrNull()?.let { it as Type } + } + ?: this + } else { + this + } +} 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 17029dd6a9..2a5deb5e20 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 @@ -16,21 +16,18 @@ package com.google.android.fhir.datacapture.fhirpath -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.datacapture.extensions.calculatedExpression import com.google.android.fhir.datacapture.extensions.findVariableExpression import com.google.android.fhir.datacapture.extensions.flattened import com.google.android.fhir.datacapture.extensions.isReferencedBy import com.google.android.fhir.datacapture.extensions.variableExpressions import org.hl7.fhir.exceptions.FHIRException -import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext 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.QuestionnaireResponse +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.Type -import org.hl7.fhir.r4.utils.FHIRPathEngine import timber.log.Timber /** @@ -58,12 +55,11 @@ object ExpressionEvaluator { */ private val variableRegex = Regex("[%]([A-Za-z0-9\\-]{1,64})") - internal val fhirPathEngine: FHIRPathEngine = - with(FhirContext.forCached(FhirVersionEnum.R4)) { - FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)).apply { - hostServices = FHIRPathEngineHostServices - } - } + /** + * Finds all the matching occurrences of FHIRPaths in x-fhir-query. See: + * https://build.fhir.org/ig/HL7/sdc/expressions.html#x-fhir-query-enhancements + */ + private val xFhirQueryEnhancementRegex = Regex("\\{\\{(.*?)\\}\\}") /** Detects if any item into list is referencing a dependent item in its calculated expression */ internal fun detectExpressionCyclicDependency( @@ -194,7 +190,7 @@ object ExpressionEvaluator { * @param variablesMap the [Map] of variables, the default value is empty map is * defined */ - internal fun extractDependentVariables( + private fun extractDependentVariables( expression: Expression, questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse, @@ -256,6 +252,70 @@ object ExpressionEvaluator { return evaluateVariable(expression, questionnaireResponse, variablesMap) } + /** + * Creates an x-fhir-query string for evaluation + * + * @param expression x-fhir-query expression + * @param launchContext if passed, the launch context to evaluate the expression against + */ + internal fun createXFhirQueryFromExpression( + expression: Expression, + launchContext: Resource? + ): String { + if (launchContext == null) { + return expression.expression + } + return evaluateXFhirEnhancement(expression, launchContext).fold(expression.expression) { + acc: String, + pair: Pair -> + acc.replace(pair.first, pair.second) + } + } + + /** + * Evaluates an x-fhir-query that contains fhir-paths, returning a sequence of pairs. The first + * element in the pair is the FhirPath expression surrounded by curly brackets {{ fhir.path }}, + * and the second element is the evaluated string result from evaluating the resource passed in. + * + * @param expression x-fhir-query expression containing a FHIRpath, e.g. + * Practitioner?active=true&{{Practitioner.name.family}} + * @param resource the launch context to evaluate the expression against + */ + private fun evaluateXFhirEnhancement( + expression: Expression, + resource: Resource + ): Sequence> = + xFhirQueryEnhancementRegex + .findAll(expression.expression) + .map { it.groupValues } + .map { (fhirPathWithParentheses, fhirPath) -> + // TODO(omarismail94): See if FHIRPathEngine.check() can be used to distinguish invalid + // expression vs an expression that is valid, but does not return one resource only. + val expressionNode = fhirPathEngine.parse(fhirPath) + val evaluatedResult = + fhirPathEngine.evaluateToString( + mapOf(resource.resourceType.name.lowercase() to resource), + null, + null, + resource, + expressionNode + ) + + // If the result of evaluating the FHIRPath expressions is an invalid query, it returns + // null. As per the spec: + // Systems SHOULD log it and continue with extraction as if the query had returned no + // data. + // See : http://build.fhir.org/ig/HL7/sdc/extraction.html#structuremap-based-extraction + if (evaluatedResult.isEmpty()) { + Timber.w( + "$fhirPath evaluated to null. The expression is either invalid, or the " + + "expression returned no, or more than one resource. The expression will be " + + "replaced with a blank string." + ) + } + fhirPathWithParentheses to evaluatedResult + } + private fun findDependentVariables(expression: Expression) = variableRegex .findAll(expression.expression) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt index a7db90684c..430ed9a5fa 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt @@ -19,12 +19,16 @@ package com.google.android.fhir.datacapture.fhirpath import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext +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.utils.FHIRPathEngine internal val fhirPathEngine: FHIRPathEngine = with(FhirContext.forCached(FhirVersionEnum.R4)) { - FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)) + FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)).apply { + hostServices = FHIRPathEngineHostServices + } } /** @@ -32,3 +36,22 @@ internal val fhirPathEngine: FHIRPathEngine = */ internal fun evaluateToDisplay(expressions: List, data: Resource) = expressions.joinToString(" ") { fhirPathEngine.evaluateToString(data, it) } + +/** + * Evaluates the expressions over list of resources [QuestionnaireResponse] and + * [QuestionnaireResponseItemComponent] and returns the resulting elements FhirPath supplements + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements %resource = + * [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] + */ +internal fun evaluateToBase( + questionnaireResponse: QuestionnaireResponse, + questionnaireResponseItemComponent: QuestionnaireResponseItemComponent, + expression: String +) = + fhirPathEngine.evaluate( + null, + questionnaireResponse, + null, + questionnaireResponseItemComponent, + expression + ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt index ed79bb2967..929988033b 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -16,8 +16,6 @@ package com.google.android.fhir.datacapture.mapping -import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.datacapture.DataCapture import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.extensions.targetStructureMap @@ -25,12 +23,12 @@ import com.google.android.fhir.datacapture.extensions.toCodeType import com.google.android.fhir.datacapture.extensions.toCoding import com.google.android.fhir.datacapture.extensions.toIdType import com.google.android.fhir.datacapture.extensions.toUriType +import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine import java.lang.reflect.Field import java.lang.reflect.Method import java.lang.reflect.ParameterizedType import java.util.Locale import org.hl7.fhir.r4.context.IWorkerContext -import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.CanonicalType @@ -52,7 +50,6 @@ import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.StructureDefinition import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.model.UriType -import org.hl7.fhir.r4.utils.FHIRPathEngine import org.hl7.fhir.r4.utils.StructureMapUtilities import timber.log.Timber @@ -77,11 +74,6 @@ import timber.log.Timber */ object ResourceMapper { - private val fhirPathEngine: FHIRPathEngine = - with(FhirContext.forCached(FhirVersionEnum.R4)) { - FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)) - } - /** * Extract FHIR resources from a [questionnaire] and [questionnaireResponse]. * @@ -750,6 +742,15 @@ private fun Class<*>.getFieldOrNull(name: String): Field? { } } +/** + * Returns a newly created [Resource] from the item extraction context extension if one and only one + * such extension exists in the questionnaire, or null otherwise. + */ +private fun Questionnaire.createResource(): Resource? = + this.extension.itemExtractionContextExtensionValue?.let { + Class.forName("org.hl7.fhir.r4.model.$it").newInstance() as Resource + } + /** * Returns the [Base] object as a [Type] as expected by * [Questionnaire.QuestionnaireItemAnswerOptionComponent.setValue]. Also, @@ -765,15 +766,6 @@ private fun Base.asExpectedType(): Type { } } -/** - * Returns a newly created [Resource] from the item extraction context extension if one and only one - * such extension exists in the questionnaire, or null otherwise. - */ -private fun Questionnaire.createResource(): Resource? = - this.extension.itemExtractionContextExtensionValue?.let { - Class.forName("org.hl7.fhir.r4.model.$it").newInstance() as Resource - } - /** * Returns a newly created [Resource] from the item extraction context extension if one and only one * such extension exists in the questionnaire item, or null otherwise. diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt index b673a8b3f7..e93aac27b7 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.compareTo import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt index 730c385d79..fb477f6879 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.compareTo import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MoreTypes.kt deleted file mode 100644 index 66504ec495..0000000000 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MoreTypes.kt +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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.validation - -import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.fhirPathEngine -import org.hl7.fhir.r4.model.Expression -import org.hl7.fhir.r4.model.Type - -internal const val CQF_CALCULATED_EXPRESSION_URL: String = - "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" - -fun Type.valueOrCalculateValue(): Type? { - return if (this.hasExtension()) { - this.extension - .firstOrNull { it.url == CQF_CALCULATED_EXPRESSION_URL } - ?.let { - val expression = (it.value as Expression).expression - fhirPathEngine.evaluate(this, expression).singleOrNull()?.let { it as Type } - } - } else { - this - } -} 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..9109163b07 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,7 @@ internal class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout questionnaireItem = questionnaireViewItem.questionnaireItem ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - question.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedTextSpanned) + question.updateTextAndVisibility(questionnaireViewItem.questionTitle) 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..1c6b916b66 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,15 +17,21 @@ 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.cqfExpression 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 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.Base +import org.hl7.fhir.r4.model.Element import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -80,6 +86,7 @@ data class QuestionnaireViewItem( { emptyList() }, + private val resolveCqfExpression: (Element) -> List = { emptyList() }, internal val draftAnswer: Any? = null, internal val enabledDisplayItems: List = emptyList() ) { @@ -199,6 +206,18 @@ data class QuestionnaireViewItem( } } + /** + * Fetches the question title that should be displayed to user. The title is evaluated from + * cqf-expression on textElement if exists, otherwise it is derived from translatable textElement + * property + */ + internal val questionTitle: Spanned? + get() = + questionnaireItem.textElement + .takeIf { it.cqfExpression != null } + ?.let { resolveCqfExpression(it).firstOrNull()?.primitiveValue()?.toSpanned() } + ?: questionnaireItem.localizedTextSpanned + /** * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the * same [Questionnaire.QuestionnaireItemComponent] and @@ -210,7 +229,8 @@ data class QuestionnaireViewItem( */ internal fun hasTheSameItem(other: QuestionnaireViewItem) = questionnaireItem === other.questionnaireItem && - questionnaireResponseItem === other.questionnaireResponseItem + questionnaireResponseItem === other.questionnaireResponseItem && + questionTitle === other.questionTitle /** * 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 8f91dff49f..395ae4c49d 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 @@ -27,6 +27,7 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_ENABLE_REVIEW_PAGE import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_STRING +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_READ_ONLY import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_REVIEW_PAGE_FIRST @@ -68,6 +69,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.hl7.fhir.instance.model.api.IBaseResource import org.hl7.fhir.r4.model.BooleanType +import org.hl7.fhir.r4.model.CodeType import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateType @@ -75,6 +77,7 @@ 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.Patient import org.hl7.fhir.r4.model.Practitioner import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire @@ -3530,6 +3533,77 @@ class QuestionnaireViewModelTest { // // // ==================================================================== // + @Test + fun `resolveAnswerExpression() should return x-fhir-query referring to patient in context`() = + runTest { + var searchString = "" + ApplicationProvider.getApplicationContext() + .dataCaptureConfiguration = + DataCaptureConfig( + xFhirQueryResolver = { xFhirQuery -> + searchString = xFhirQuery + emptyList() + } + ) + + val patientId = "123" + val patient = + Patient().apply { + id = patientId + active = true + gender = Enumerations.AdministrativeGender.MALE + addName(HumanName().apply { this.family = "Johnny" }) + } + + val questionnaire = + Questionnaire().apply { + extension = + listOf( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext" + ) + .apply { + addExtension( + "name", + Coding( + "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext", + "patient", + "Patient" + ) + ) + addExtension("type", CodeType("Patient")) + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a" + text = "answer expression question text" + type = Questionnaire.QuestionnaireItemType.REFERENCE + extension = + listOf( + Extension( + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-answerExpression", + Expression().apply { + this.expression = "Observation?subject={{%patient.id}}" + this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() + } + ) + ) + } + ) + } + state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) + state.set( + EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_JSON_STRING, + printer.encodeResourceToString(patient) + ) + + val viewModel = QuestionnaireViewModel(context, state) + viewModel.resolveAnswerExpression(questionnaire.itemFirstRep) + + assertThat(searchString).isEqualTo("Observation?subject=Patient/$patientId") + } + @Test fun `resolveAnswerExpression() should return questionnaire item answer options for answer expression and choice column`() = runTest { diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnairesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnairesTest.kt index 4420cdc1bf..1f6f054f39 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnairesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnairesTest.kt @@ -17,7 +17,9 @@ package com.google.android.fhir.datacapture.extensions import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith import org.hl7.fhir.r4.model.CanonicalType +import org.hl7.fhir.r4.model.CodeType import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Extension @@ -107,4 +109,60 @@ class MoreQuestionnairesTest { val questionnaire = Questionnaire() assertThat(questionnaire.entryMode).isNull() } + + @Test + fun `should throw exception if resource type in context is not part of launchContext set`() { + val launchContextExtension = + Extension("http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext") + .apply { + addExtension( + "name", + Coding( + "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext", + "observation", + "Observation" + ) + ) + addExtension("type", CodeType("Observation")) + } + + val errorMessage = + assertFailsWith { + validateLaunchContext(launchContextExtension, "Patient") + } + .localizedMessage + + assertThat(errorMessage) + .isEqualTo( + "The value of the extension:name field in " + + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext is " + + "not one of the ones defined in http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext." + ) + } + + @Test + fun `should throw exception if resource type in context is different to what is in launchContext extension`() { + val launchContextExtension = + Extension("http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext") + .apply { + addExtension( + "name", + Coding("http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext", "encounter", "Encounter") + ) + addExtension("type", CodeType("Encounter")) + } + + val errorMessage = + assertFailsWith { + validateLaunchContext(launchContextExtension, "Patient") + } + .localizedMessage + + assertThat(errorMessage) + .isEqualTo( + "The resource type set in the extension:type field in " + + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext does " + + "not match the resource type of the context passed in: Patient." + ) + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt index 28da080b35..ad0b11f2c3 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt @@ -20,6 +20,7 @@ import android.os.Build import ca.uhn.fhir.model.api.TemporalPrecisionEnum import com.google.common.truth.Truth.assertThat import java.time.Instant +import java.time.LocalDate import java.time.ZoneId import java.util.Date import java.util.TimeZone @@ -29,7 +30,10 @@ import org.hl7.fhir.r4.model.CanonicalType import org.hl7.fhir.r4.model.CodeType import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateTimeType +import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.DecimalType +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.InstantType import org.hl7.fhir.r4.model.IntegerType @@ -194,4 +198,48 @@ class MoreTypesTest { val code = Coding("fakeSystem", "fakeCode", "fakeDisplay").toCodeType() assertThat(code.equalsDeep(CodeType("fakeCode"))).isTrue() } + + @Test + fun `should return calculated value for cqf expression`() { + val today = LocalDate.now().toString() + val type = + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "today()" + } + ) + ) + } + assertThat((type.valueOrCalculateValue() as DateType).valueAsString).isEqualTo(today) + } + + @Test + fun `should return calculated value for a non-cqf extension`() { + LocalDate.now().toString() + val type = + DateType().apply { + extension = + listOf( + Extension( + "http://hl7.org/fhir/StructureDefinition/my-own-expression", + Expression().apply { + language = "text/fhirpath" + expression = "today()" + } + ) + ) + } + assertThat((type.valueOrCalculateValue() as DateType).valueAsString).isEqualTo(null) + } + + @Test + fun `should return entered value when no cqf expression is defined`() { + val type = IntegerType().apply { value = 500 } + assertThat((type.valueOrCalculateValue() as IntegerType).value).isEqualTo(500) + } } 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 e5a293dbca..f526109255 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 @@ -25,10 +25,17 @@ import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluate import com.google.common.truth.Truth.assertThat import java.util.Calendar import java.util.Date +import java.util.UUID import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.CodeableConcept +import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateType +import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Expression +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 @@ -665,4 +672,103 @@ class ExpressionEvaluatorTest { assertThat(exception.message) .isEqualTo("a-birthdate and a-age-years have cyclic dependency in expression based extension") } + + @Test + fun `createXFhirQueryFromExpression() should capture all FHIR paths`() { + val expression = + Expression().apply { + this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() + this.expression = + "Practitioner?var1={{random}}&var2={{ random }}&var3={{ random}}&var4={{random }}" + } + + val expressionsToEvaluate = + ExpressionEvaluator.createXFhirQueryFromExpression(expression, Practitioner()) + + assertThat(expressionsToEvaluate).isEqualTo("Practitioner?var1=&var2=&var3=&var4=") + } + + @Test + fun `createXFhirQueryFromExpression() should evaluate to empty string for field that does not exist in resource`() { + val practitioner = + Practitioner().apply { + id = UUID.randomUUID().toString() + active = true + addName(HumanName().apply { this.family = "John" }) + } + + val expression = + Expression().apply { + this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() + this.expression = "Practitioner?gender={{Practitioner.gender}}" + } + + val expressionsToEvaluate = + ExpressionEvaluator.createXFhirQueryFromExpression(expression, practitioner) + assertThat(expressionsToEvaluate).isEqualTo("Practitioner?gender=") + } + + @Test + fun `createXFhirQueryFromExpression() should evaluate correct expression`() { + val practitioner = + Practitioner().apply { + id = UUID.randomUUID().toString() + active = true + gender = Enumerations.AdministrativeGender.MALE + addName(HumanName().apply { this.family = "John" }) + } + + val expression = + Expression().apply { + this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() + this.expression = "Practitioner?gender={{Practitioner.gender}}" + } + + val expressionsToEvaluate = + ExpressionEvaluator.createXFhirQueryFromExpression(expression, practitioner) + assertThat(expressionsToEvaluate).isEqualTo("Practitioner?gender=male") + } + + @Test + fun `createXFhirQueryFromExpression() should return empty string if the resource provided does not match the type in the expression`() { + val practitioner = + Practitioner().apply { + id = UUID.randomUUID().toString() + active = true + gender = Enumerations.AdministrativeGender.MALE + addName(HumanName().apply { this.family = "John" }) + } + + val expression = + Expression().apply { + this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() + this.expression = "Practitioner?gender={{%patient.gender}}" + } + + val expressionsToEvaluate = + ExpressionEvaluator.createXFhirQueryFromExpression(expression, practitioner) + assertThat(expressionsToEvaluate).isEqualTo("Practitioner?gender=") + } + + @Test + fun `createXFhirQueryFromExpression() should evaluate fhirPath with percent sign`() { + val patient = + Patient().apply { + id = UUID.randomUUID().toString() + active = true + gender = Enumerations.AdministrativeGender.MALE + addName(HumanName().apply { this.family = "John" }) + maritalStatus = CodeableConcept().addCoding(Coding("theSystem", "theCode", "Single")) + } + + val expression = + Expression().apply { + this.language = Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() + this.expression = "Patient?maritalStatus-display={{%patient.maritalStatus.coding.display}}" + } + + val expressionsToEvaluate = + ExpressionEvaluator.createXFhirQueryFromExpression(expression, patient) + assertThat(expressionsToEvaluate).isEqualTo("Patient?maritalStatus-display=Single") + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt index 48fa04a963..a1a6a98deb 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat import java.text.SimpleDateFormat import java.time.LocalDate @@ -122,7 +123,7 @@ class MaxValueValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today()" @@ -154,7 +155,7 @@ class MaxValueValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today() + 5 'days' " diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt index c3e68e5e01..423462c0f9 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt @@ -20,6 +20,7 @@ import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat import java.text.SimpleDateFormat import java.time.LocalDate @@ -109,7 +110,7 @@ class MinValueValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today() - 1 'days'" @@ -160,7 +161,7 @@ class MinValueValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today() - 1 'days'" @@ -200,7 +201,7 @@ class MinValueValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today()" diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MoreTypesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MoreTypesTest.kt deleted file mode 100644 index dfcf539e5a..0000000000 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MoreTypesTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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.validation - -import android.content.Context -import android.os.Build -import androidx.test.core.app.ApplicationProvider -import com.google.common.truth.Truth.assertThat -import java.time.LocalDate -import org.hl7.fhir.r4.model.DateType -import org.hl7.fhir.r4.model.Expression -import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.IntegerType -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.P]) -class MoreTypesTest { - lateinit var context: Context - - @Before - fun setup() { - context = ApplicationProvider.getApplicationContext() - } - - @Test - fun `should return calculated value for cqf expression`() { - val today = LocalDate.now().toString() - val type = - DateType().apply { - extension = - listOf( - Extension( - CQF_CALCULATED_EXPRESSION_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today()" - } - ) - ) - } - assertThat((type.valueOrCalculateValue() as? DateType)?.valueAsString).isEqualTo(today) - } - - @Test - fun `should return entered value when no cqf expression is defined`() { - val type = IntegerType().apply { value = 500 } - assertThat((type.valueOrCalculateValue() as? IntegerType)?.value).isEqualTo(500) - } -} diff --git a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt index 38f4e27394..d0931d84b7 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt @@ -24,7 +24,9 @@ import com.google.android.fhir.FhirEngineConfiguration import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.ServerConfiguration import com.google.android.fhir.datacapture.DataCaptureConfig +import com.google.android.fhir.datacapture.XFhirQueryResolver import com.google.android.fhir.demo.data.FhirSyncWorker +import com.google.android.fhir.search.search import com.google.android.fhir.sync.Sync import com.google.android.fhir.sync.remote.HttpLogger import timber.log.Timber @@ -62,6 +64,7 @@ class FhirApplication : Application(), DataCaptureConfig.Provider { dataCaptureConfig = DataCaptureConfig().apply { urlResolver = ReferenceUrlResolver(this@FhirApplication as Context) + xFhirQueryResolver = XFhirQueryResolver { fhirEngine.search(it) } } } From c8b7694cd5e135f0b9c9dc09cfb59f77df6752db Mon Sep 17 00:00:00 2001 From: maimoonak Date: Fri, 7 Apr 2023 17:26:33 +0500 Subject: [PATCH 02/18] Fix issue with failing tests --- .../datacapture/views/QuestionnaireViewItem.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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 1c6b916b66..0e6dd47974 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 @@ -211,12 +211,12 @@ data class QuestionnaireViewItem( * cqf-expression on textElement if exists, otherwise it is derived from translatable textElement * property */ - internal val questionTitle: Spanned? - get() = - questionnaireItem.textElement - .takeIf { it.cqfExpression != null } - ?.let { resolveCqfExpression(it).firstOrNull()?.primitiveValue()?.toSpanned() } - ?: questionnaireItem.localizedTextSpanned + internal val questionTitle: Spanned? by lazy { + questionnaireItem.textElement + .takeIf { it.cqfExpression != null } + ?.let { resolveCqfExpression(it).firstOrNull()?.primitiveValue()?.toSpanned() } + ?: questionnaireItem.localizedTextSpanned + } /** * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the @@ -230,7 +230,7 @@ data class QuestionnaireViewItem( internal fun hasTheSameItem(other: QuestionnaireViewItem) = questionnaireItem === other.questionnaireItem && questionnaireResponseItem === other.questionnaireResponseItem && - questionTitle === other.questionTitle + questionTitle == other.questionTitle /** * Returns whether this [QuestionnaireViewItem] and the `other` [QuestionnaireViewItem] have the From eef0576aade074d33721c7b59d106191172d5ab2 Mon Sep 17 00:00:00 2001 From: maimoonak Date: Wed, 19 Apr 2023 12:08:25 +0500 Subject: [PATCH 03/18] Add tests | Fix variables expression logic issue --- .../datacapture/QuestionnaireViewModel.kt | 14 +- .../fhirpath/ExpressionEvaluator.kt | 42 ++++++ .../fhir/datacapture/fhirpath/FhirPathUtil.kt | 21 --- .../datacapture/QuestionnaireViewModelTest.kt | 140 ++++++++++++++++++ 4 files changed, 192 insertions(+), 25 deletions(-) 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 2043bfb02b..01ae99c803 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 @@ -50,7 +50,7 @@ import com.google.android.fhir.datacapture.extensions.validateLaunchContext 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.evaluateToBase +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCqfExpression import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated @@ -538,15 +538,19 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } internal fun resolveCqfExpression( + questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, element: Element, ): List { val cqfExpression = element.cqfExpression ?: return emptyList() if (cqfExpression.isFhirPath) { - return evaluateToBase( + return evaluateCqfExpression( + questionnaire, questionnaireResponse, + questionnaireItem, questionnaireResponseItem, - cqfExpression.expression + cqfExpression, + questionnaireItemParentMap ) } else throw UnsupportedOperationException("${cqfExpression.language} not supported yet") } @@ -703,7 +707,9 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat answersChangedCallback = answersChangedCallback, resolveAnswerValueSet = { resolveAnswerValueSet(it) }, resolveAnswerExpression = { resolveAnswerExpression(it) }, - resolveCqfExpression = { resolveCqfExpression(questionnaireResponseItem, it) }, + resolveCqfExpression = { + resolveCqfExpression(questionnaireItem, questionnaireResponseItem, it) + }, draftAnswer = draftAnswerMap[questionnaireResponseItem], enabledDisplayItems = questionnaireItem.item.filter { 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..6c19be2200 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,6 +83,46 @@ object ExpressionEvaluator { } } + /** + * Evaluates the expressions over list of resources [QuestionnaireResponse] and + * [QuestionnaireResponseItemComponent] and returns the resulting elements FhirPath supplements + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements %resource = + * [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] + */ + /** + * Returns a list of [Base] as the evaluated value for cqf expression extension. Expression runs + * over questionnaireResponse with fhirpath supplements values + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. %resource = + * [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] + */ + fun evaluateCqfExpression( + 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 diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt index 430ed9a5fa..76f4c79ab7 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt @@ -19,8 +19,6 @@ package com.google.android.fhir.datacapture.fhirpath import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext -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.utils.FHIRPathEngine @@ -36,22 +34,3 @@ internal val fhirPathEngine: FHIRPathEngine = */ internal fun evaluateToDisplay(expressions: List, data: Resource) = expressions.joinToString(" ") { fhirPathEngine.evaluateToString(data, it) } - -/** - * Evaluates the expressions over list of resources [QuestionnaireResponse] and - * [QuestionnaireResponseItemComponent] and returns the resulting elements FhirPath supplements - * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements %resource = - * [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] - */ -internal fun evaluateToBase( - questionnaireResponse: QuestionnaireResponse, - questionnaireResponseItemComponent: QuestionnaireResponseItemComponent, - expression: String -) = - fhirPathEngine.evaluate( - null, - questionnaireResponse, - null, - questionnaireResponseItemComponent, - expression - ) 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..aafb8a421b 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!!.questionTitle).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.questionTitle.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.questionTitle.toString()).isEqualTo("Sum of variables is 3") + } + private fun createQuestionnaireViewModel( questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse? = null, From 98b9af72b20de9c5259d38119baed54e41774e63 Mon Sep 17 00:00:00 2001 From: maimoonak Date: Wed, 19 Apr 2023 12:11:26 +0500 Subject: [PATCH 04/18] Remove extra javadoc --- .../fhir/datacapture/fhirpath/ExpressionEvaluator.kt | 6 ------ 1 file changed, 6 deletions(-) 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 6c19be2200..99e88f1b50 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 @@ -83,12 +83,6 @@ object ExpressionEvaluator { } } - /** - * Evaluates the expressions over list of resources [QuestionnaireResponse] and - * [QuestionnaireResponseItemComponent] and returns the resulting elements FhirPath supplements - * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements %resource = - * [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] - */ /** * Returns a list of [Base] as the evaluated value for cqf expression extension. Expression runs * over questionnaireResponse with fhirpath supplements values From ffa68c81fa2678f82c811eb9b80873d05c0cb5a7 Mon Sep 17 00:00:00 2001 From: maimoonak Date: Wed, 26 Apr 2023 19:16:37 +0500 Subject: [PATCH 05/18] Remove function call from questionItem and use response text for dynamic eval --- .../behavior_dynamic_question_title.json | 22 +++++++++---------- .../datacapture/QuestionnaireViewModel.kt | 16 +++++++++----- .../views/QuestionnaireViewItem.kt | 15 ++++--------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/catalog/src/main/assets/behavior_dynamic_question_title.json b/catalog/src/main/assets/behavior_dynamic_question_title.json index a24fb3a936..50af5eca49 100644 --- a/catalog/src/main/assets/behavior_dynamic_question_title.json +++ b/catalog/src/main/assets/behavior_dynamic_question_title.json @@ -2,7 +2,7 @@ "resourceType": "Questionnaire", "item": [ { - "text": "Choose vaccination administered", + "text": "Choose an option below", "type": "choice", "extension": [ { @@ -23,26 +23,26 @@ "answerOption": [ { "valueCoding": { - "code": "bcg", - "display": "BCG - Bacillus Calmette Guerin Vaccine" + "code": "option1", + "display": "First Option" } }, { "valueCoding": { - "code": "opv 0", - "display": "OPV 0 - Oral Poliovirus Vaccine" + "code": "option2", + "display": "Second Option" } }, { "valueCoding": { - "code": "penta 1", - "display": "Penta 1 - Pentavalent (DPT + Hep B + Hib) Vaccine" + "code": "option3", + "display": "Third Option" } }, { "valueCoding": { - "code": "pcv 1", - "display": "PCV 1 - Pneumococcal Conjugate Vaccine" + "code": "option4", + "display": "Fourth Option" } } ] @@ -50,14 +50,14 @@ { "linkId": "2", "required": true, - "text": "Vaccine Date", + "text": "Option Date", "_text": { "extension": [ { "url": "http://hl7.org/fhir/StructureDefinition/cqf-expression", "valueExpression": { "language": "text/fhirpath", - "expression": "'Select '+ %resource.descendants().where(linkId = '1').answer.value.display + ' Date'" + "expression": "'Provide \"'+ %resource.descendants().where(linkId = '1').answer.value.display + '\" Date'" } } ] 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 01ae99c803..839e0d2307 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 @@ -537,7 +537,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat return options } - internal fun resolveCqfExpression( + private fun resolveCqfExpression( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, element: Element, @@ -696,6 +696,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } else { NotValidated } + + // set title dynamically from cqf expression on textElement + questionnaireResponseItem.apply { + resolveCqfExpression(questionnaireItem, this, questionnaireItem.textElement) + .firstOrNull() + ?.let { text = it.primitiveValue() } + } + val items = buildList { // Add an item for the question itself add( @@ -707,9 +715,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat answersChangedCallback = answersChangedCallback, resolveAnswerValueSet = { resolveAnswerValueSet(it) }, resolveAnswerExpression = { resolveAnswerExpression(it) }, - resolveCqfExpression = { - resolveCqfExpression(questionnaireItem, questionnaireResponseItem, it) - }, draftAnswer = draftAnswerMap[questionnaireResponseItem], enabledDisplayItems = questionnaireItem.item.filter { @@ -799,7 +804,8 @@ 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/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index 0e6dd47974..97b0f185e6 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 @@ -22,7 +22,6 @@ 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.cqfExpression import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.validation.NotValidated @@ -30,8 +29,6 @@ import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking -import org.hl7.fhir.r4.model.Base -import org.hl7.fhir.r4.model.Element import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -86,7 +83,6 @@ data class QuestionnaireViewItem( { emptyList() }, - private val resolveCqfExpression: (Element) -> List = { emptyList() }, internal val draftAnswer: Any? = null, internal val enabledDisplayItems: List = emptyList() ) { @@ -207,15 +203,12 @@ data class QuestionnaireViewItem( } /** - * Fetches the question title that should be displayed to user. The title is evaluated from - * cqf-expression on textElement if exists, otherwise it is derived from translatable textElement - * property + * Fetches the question title that should be displayed to user. The title is fetched from + * [Questionnaire.QuestionnaireResponseItemComponent] if exists, otherwise it is derived from + * translatable textElement property of [QuestionnaireResponse.QuestionnaireItemComponent] */ internal val questionTitle: Spanned? by lazy { - questionnaireItem.textElement - .takeIf { it.cqfExpression != null } - ?.let { resolveCqfExpression(it).firstOrNull()?.primitiveValue()?.toSpanned() } - ?: questionnaireItem.localizedTextSpanned + questionnaireResponseItem.text?.toSpanned() ?: questionnaireItem.localizedTextSpanned } /** From 0409406abf41f9605fe6e5a19d64364fa10b5363 Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Thu, 27 Apr 2023 10:36:29 -0400 Subject: [PATCH 06/18] 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> --- .../fhirpath/ExpressionEvaluator.kt | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) 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 99e88f1b50..b365e58082 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 @@ -97,24 +97,24 @@ object ExpressionEvaluator { 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 - ) + val appContext = mutableMapOf() + return with(appContext) { + extractDependentVariables( + expression, + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireItem, + appContext + ) + fhirPathEngine.evaluate( + appContext, + questionnaireResponse, + null, + questionnaireResponseItem, + expression.expression + ) + } } /** From 668b58f7c11b98fb52c7d1e004230fe6f12005ba Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Thu, 27 Apr 2023 10:37:59 -0400 Subject: [PATCH 07/18] Update datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt Co-authored-by: santosh-pingle <86107848+santosh-pingle@users.noreply.github.com> --- .../android/fhir/datacapture/QuestionnaireViewModel.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 839e0d2307..2f4e6e28b0 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 @@ -804,8 +804,9 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } .map { (questionnaireItem, questionnaireResponseItem) -> questionnaireResponseItem.apply { - if (text.isNullOrBlank()) text = questionnaireItem.localizedTextSpanned?.toString() - + if (text.isNullOrBlank()) { + text = questionnaireItem.localizedTextSpanned?.toString() + } // Nested group items item = getEnabledResponseItems(questionnaireItem.item, questionnaireResponseItem.item) // Nested question items From 9e21fec4f6e188d59332fd4cdb3ee83f496f728b Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Thu, 27 Apr 2023 10:50:30 -0400 Subject: [PATCH 08/18] Update catalog/src/main/res/values/strings.xml Co-authored-by: Jing Tang --- catalog/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index 604131dfea..3c3f8d3233 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -45,7 +45,7 @@ >If Yes is selected, a follow-up question is displayed. If No is selected, no follow-up questions are displayed. Dynamic Question Title + >Dynamic Question Text Input age to automatically calculate birthdate until birthdate is updated manually. From 101b672183f571fc59bd2ccaae479c20a313630a Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Thu, 27 Apr 2023 10:53:05 -0400 Subject: [PATCH 09/18] Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt Co-authored-by: Jing Tang --- .../android/fhir/datacapture/views/QuestionnaireViewItem.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 97b0f185e6..984a778ba6 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 @@ -207,7 +207,7 @@ data class QuestionnaireViewItem( * [Questionnaire.QuestionnaireResponseItemComponent] if exists, otherwise it is derived from * translatable textElement property of [QuestionnaireResponse.QuestionnaireItemComponent] */ - internal val questionTitle: Spanned? by lazy { + internal val questionText: Spanned? by lazy { questionnaireResponseItem.text?.toSpanned() ?: questionnaireItem.localizedTextSpanned } From 85406da14e4d4c9a48048744b98b827046a57b75 Mon Sep 17 00:00:00 2001 From: maimoonak Date: Fri, 28 Apr 2023 01:12:14 +0500 Subject: [PATCH 10/18] rename catalog files | add tests | add to group view item | modify same item check --- ...on => behavior_dynamic_question_text.json} | 0 .../fhir/catalog/BehaviorListViewModel.kt | 8 +-- ...avior.xml => ic_dynamic_text_behavior.xml} | 0 catalog/src/main/res/values/strings.xml | 2 +- .../datacapture/QuestionnaireEditAdapter.kt | 2 +- .../fhir/datacapture/views/GroupHeaderView.kt | 4 +- .../fhir/datacapture/views/HeaderView.kt | 3 +- .../views/QuestionnaireViewItem.kt | 20 +++--- .../datacapture/QuestionnaireViewModelTest.kt | 6 +- .../datacapture/views/GroupHeaderViewTest.kt | 41 +++++++++++- .../fhir/datacapture/views/HeaderViewTest.kt | 41 +++++++++++- .../views/QuestionnaireViewItemTest.kt | 66 +++++++++++++++---- 12 files changed, 155 insertions(+), 38 deletions(-) rename catalog/src/main/assets/{behavior_dynamic_question_title.json => behavior_dynamic_question_text.json} (100%) rename catalog/src/main/res/drawable/{ic_dynamic_title_behavior.xml => ic_dynamic_text_behavior.xml} (100%) diff --git a/catalog/src/main/assets/behavior_dynamic_question_title.json b/catalog/src/main/assets/behavior_dynamic_question_text.json similarity index 100% rename from catalog/src/main/assets/behavior_dynamic_question_title.json rename to catalog/src/main/assets/behavior_dynamic_question_text.json 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 dd1561d036..e291f3ae48 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 @@ -43,10 +43,10 @@ class BehaviorListViewModel(application: Application) : AndroidViewModel(applica R.string.behavior_name_skip_logic, "behavior_skip_logic.json" ), - DYNAMIC_QUESTION_TITLE( - R.drawable.ic_dynamic_title_behavior, - R.string.behavior_name_dynamic_question_title, - "behavior_dynamic_question_title.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_title_behavior.xml b/catalog/src/main/res/drawable/ic_dynamic_text_behavior.xml similarity index 100% rename from catalog/src/main/res/drawable/ic_dynamic_title_behavior.xml rename to catalog/src/main/res/drawable/ic_dynamic_text_behavior.xml diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index 3c3f8d3233..5b0f4a7f52 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -44,7 +44,7 @@ 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. Dynamic Question Text @@ -241,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 aafb8a421b..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 @@ -4792,7 +4792,7 @@ class QuestionnaireViewModelTest { } job.join() - assertThat(descriptionResponseItem!!.questionTitle).isNull() + assertThat(descriptionResponseItem!!.questionText).isNull() val ageItemUpdated = viewModel.questionnaireStateFlow.value.items .first { it.asQuestionOrNull()?.questionnaireItem?.linkId == "a-age" } @@ -4820,7 +4820,7 @@ class QuestionnaireViewModelTest { .first { it.asQuestionOrNull()?.questionnaireItem?.linkId == "a-description" } .asQuestion() - assertThat(descriptionItemUpdated.questionTitle.toString()) + assertThat(descriptionItemUpdated.questionText.toString()) .isEqualTo("Notes for child of age 2 years") } @@ -4875,7 +4875,7 @@ class QuestionnaireViewModelTest { .first { it.asQuestionOrNull()?.questionnaireItem?.linkId == "a-description" } .asQuestion() - assertThat(descriptionItem.questionTitle.toString()).isEqualTo("Sum of variables is 3") + assertThat(descriptionItem.questionText.toString()).isEqualTo("Sum of variables is 3") } private fun createQuestionnaireViewModel( 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(), From 291e0a1231ea888afdc976fbeb22b8f9d894fef2 Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Fri, 12 May 2023 04:34:23 -0400 Subject: [PATCH 11/18] Update catalog/src/main/res/values/strings.xml Co-authored-by: Jing Tang --- catalog/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index eddd762f01..6ab1e08526 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -46,7 +46,7 @@ Context variable Dynamic Question Text + >Dynamic question text Input age to automatically calculate birthdate until birthdate is updated manually. From 38c0f4efa4cff13ee5fb53606cec5e041585643f Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Fri, 12 May 2023 04:39:30 -0400 Subject: [PATCH 12/18] Update datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt Co-authored-by: Jing Tang --- .../fhir/datacapture/fhirpath/ExpressionEvaluator.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 b365e58082..b33771b993 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 @@ -84,10 +84,13 @@ object ExpressionEvaluator { } /** - * Returns a list of [Base] as the evaluated value for cqf expression extension. Expression runs - * over questionnaireResponse with fhirpath supplements values - * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. %resource = - * [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] + * Returns the evaluation result of the CQF expression. + * + * FHIRPath supplements are handled according to + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements. + * + * %resource = [QuestionnaireResponse] + * %context = [QuestionnaireResponseItemComponent] */ fun evaluateCqfExpression( questionnaire: Questionnaire, From 4d5f5f1d45a1a02dd26b787262224bb7ad0e2763 Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Fri, 12 May 2023 04:39:58 -0400 Subject: [PATCH 13/18] Update datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt Co-authored-by: Jing Tang --- .../google/android/fhir/datacapture/QuestionnaireViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7b35b36e0a..0119c89130 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 @@ -698,7 +698,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat NotValidated } - // set title dynamically from cqf expression on textElement + // Set question text dynamically from CQL expression questionnaireResponseItem.apply { resolveCqfExpression(questionnaireItem, this, questionnaireItem.textElement) .firstOrNull() From 57c27f2185846c705dcfbab08e43214711f14bf7 Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Fri, 12 May 2023 04:40:08 -0400 Subject: [PATCH 14/18] Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/HeaderView.kt Co-authored-by: Jing Tang --- .../com/google/android/fhir/datacapture/views/HeaderView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 31ec59e9cb..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 @@ -48,7 +48,7 @@ internal class HeaderView(context: Context, attrs: AttributeSet?) : LinearLayout questionnaireItem = questionnaireViewItem.questionnaireItem ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - // use questionText to ensure cqf-expression derived text is prioritized over localized text + // CQF expression takes precedence over static question text question.updateTextAndVisibility(questionnaireViewItem.questionText) hint.updateTextAndVisibility( questionnaireViewItem.enabledDisplayItems.localizedInstructionsSpanned From 8dba1e092be64efeaf8249d7944d5a65e24f3991 Mon Sep 17 00:00:00 2001 From: maimoonak <4829880+maimoonak@users.noreply.github.com> Date: Fri, 12 May 2023 04:40:19 -0400 Subject: [PATCH 15/18] Update datacapture/src/main/java/com/google/android/fhir/datacapture/views/GroupHeaderView.kt Co-authored-by: Jing Tang --- .../google/android/fhir/datacapture/views/GroupHeaderView.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 69da3cf28d..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 @@ -47,7 +47,7 @@ internal class GroupHeaderView(context: Context, attrs: AttributeSet?) : questionnaireItem = questionnaireViewItem.questionnaireItem ) prefix.updateTextAndVisibility(questionnaireViewItem.questionnaireItem.localizedPrefixSpanned) - // use questionText to ensure cqf-expression derived text is prioritized over localized text + // CQF expression takes precedence over static question text question.updateTextAndVisibility(questionnaireViewItem.questionText) hint.updateTextAndVisibility( questionnaireViewItem.enabledDisplayItems.localizedInstructionsSpanned From 83a7e8054cad62e430de743100a20437cff05903 Mon Sep 17 00:00:00 2001 From: maimoonak Date: Fri, 12 May 2023 15:02:51 +0500 Subject: [PATCH 16/18] Move code to MoreElements --- .../fhir/datacapture/extensions/MoreElements.kt | 14 ++++++++++++++ .../fhir/datacapture/extensions/MoreExpressions.kt | 9 --------- .../datacapture/fhirpath/ExpressionEvaluator.kt | 6 ++---- 3 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreElements.kt 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..6b63b4b7ef --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreElements.kt @@ -0,0 +1,14 @@ +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) + } \ No newline at end of file diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt index dc22d0698b..ef436340ac 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt @@ -20,15 +20,6 @@ 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) - } - internal val Expression.isXFhirQuery: Boolean get() = this.language == Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() 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 b365e58082..37a2f35d4d 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 @@ -97,8 +97,7 @@ object ExpressionEvaluator { expression: Expression, questionnaireItemParentMap: Map ): List { - val appContext = mutableMapOf() - return with(appContext) { + val appContext = mutableMapOf() extractDependentVariables( expression, questionnaire, @@ -107,14 +106,13 @@ object ExpressionEvaluator { questionnaireItem, appContext ) - fhirPathEngine.evaluate( + return fhirPathEngine.evaluate( appContext, questionnaireResponse, null, questionnaireResponseItem, expression.expression ) - } } /** From df852afefb4d5ece3c0d4b5b91b36ee04eefe57e Mon Sep 17 00:00:00 2001 From: maimoonak Date: Fri, 12 May 2023 17:19:42 +0500 Subject: [PATCH 17/18] Make expression eval generic method --- .../datacapture/QuestionnaireViewModel.kt | 35 +++++---- .../datacapture/extensions/MoreElements.kt | 26 +++++-- .../datacapture/extensions/MoreExpressions.kt | 2 - .../fhirpath/ExpressionEvaluator.kt | 73 ++++++++----------- .../fhirpath/ExpressionEvaluatorTest.kt | 2 + 5 files changed, 75 insertions(+), 63 deletions(-) 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 0119c89130..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 @@ -51,7 +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.evaluateCqfExpression +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 @@ -321,7 +321,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem) - updateDependentQuestionnaireResponseItems(questionnaireItem) + updateDependentQuestionnaireResponseItems(questionnaireItem, questionnaireResponseItem) modificationCount.update { it + 1 } } @@ -443,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 @@ -544,16 +549,18 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat element: Element, ): List { val cqfExpression = element.cqfExpression ?: return emptyList() - if (cqfExpression.isFhirPath) { - return evaluateCqfExpression( - questionnaire, - questionnaireResponse, - questionnaireItem, - questionnaireResponseItem, - cqfExpression, - questionnaireItemParentMap - ) - } else throw UnsupportedOperationException("${cqfExpression.language} not supported yet") + + if (!cqfExpression.isFhirPath) { + throw UnsupportedOperationException("${cqfExpression.language} not supported yet") + } + return evaluateExpression( + questionnaire, + questionnaireResponse, + questionnaireItem, + questionnaireResponseItem, + cqfExpression, + questionnaireItemParentMap + ) } private suspend fun loadAnswerExpressionOptions( 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 index 6b63b4b7ef..f7f944078f 100644 --- 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 @@ -1,3 +1,19 @@ +/* + * 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 @@ -5,10 +21,10 @@ 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" + "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) - } \ No newline at end of file + 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/extensions/MoreExpressions.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt index ef436340ac..5c77c0d5a2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreExpressions.kt @@ -16,9 +16,7 @@ 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 val Expression.isXFhirQuery: Boolean get() = this.language == Expression.ExpressionLanguage.APPLICATION_XFHIRQUERY.toCode() 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 6fe383089f..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 @@ -84,38 +84,39 @@ object ExpressionEvaluator { } /** - * Returns the evaluation result of the CQF expression. - * + * 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] + * %resource = [QuestionnaireResponse] %context = [QuestionnaireResponseItemComponent] */ - fun evaluateCqfExpression( + fun evaluateExpression( questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse, questionnaireItem: QuestionnaireItemComponent, - questionnaireResponseItem: QuestionnaireResponseItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent?, expression: Expression, questionnaireItemParentMap: Map ): List { - val appContext = mutableMapOf() - extractDependentVariables( - expression, - questionnaire, - questionnaireResponse, - questionnaireItemParentMap, - questionnaireItem, - appContext - ) - return fhirPathEngine.evaluate( - appContext, - questionnaireResponse, - null, - questionnaireResponseItem, - expression.expression - ) + val appContext = + mutableMapOf().apply { + extractDependentVariables( + expression, + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireItem, + this + ) + } + return fhirPathEngine.evaluate( + appContext, + questionnaireResponse, + null, + questionnaireResponseItem, + expression.expression + ) } /** @@ -123,11 +124,11 @@ object ExpressionEvaluator { * 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() @@ -139,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/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() From 7930f31506b81aa0b7d97ebcb2ec25115397ad8e Mon Sep 17 00:00:00 2001 From: maimoonak Date: Fri, 12 May 2023 20:10:20 +0500 Subject: [PATCH 18/18] Add android test for cqf --- ...stionnaire_with_dynamic_question_text.json | 56 +++++++++++++++++++ .../QuestionnaireUiEspressoTest.kt | 24 ++++++++ 2 files changed, 80 insertions(+) create mode 100644 datacapture/sampledata/questionnaire_with_dynamic_question_text.json 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 =