Skip to content

Commit

Permalink
Pack repeated groups into answers (#1936)
Browse files Browse the repository at this point in the history
* Pack repeated groups into answers

* Run spotless apply

* Add negative test cases for packing/unpacking repeated groups

* Update TestingUtils.kt

* Add test for repeated groups in questionnaire view model

* Change fun signature unpackRepeatedGroups to use List

* Deconstruct it

* Rename files and update URL

* Inline variable isRepeatedGroup

* Revert TestingUtils.kt

* Update TestingUtils.kt
  • Loading branch information
jingtang10 committed Mar 29, 2023
1 parent 9396c07 commit 256ab75
Show file tree
Hide file tree
Showing 7 changed files with 548 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ import com.google.android.fhir.datacapture.extensions.isHidden
import com.google.android.fhir.datacapture.extensions.isPaginated
import com.google.android.fhir.datacapture.extensions.isXFhirQuery
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.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency
import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions
import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine
Expand Down Expand Up @@ -143,6 +145,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
}
}
}
questionnaireResponse.packRepeatedGroups()
}

/** The map from each item in the [Questionnaire] to its parent. */
Expand Down Expand Up @@ -306,6 +309,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
fun getQuestionnaireResponse(): QuestionnaireResponse {
return questionnaireResponse.copy().apply {
item = getEnabledResponseItems(this@QuestionnaireViewModel.questionnaire.item, item)
unpackRepeatedGroups(this@QuestionnaireViewModel.questionnaire)
}
}

Expand Down Expand Up @@ -728,60 +732,18 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
questionnaireResponseItem,
)
}
.flatMap { (questionnaireItem, questionnaireResponseItem) ->
val isRepeatedGroup =
questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP &&
questionnaireItem.repeats
if (isRepeatedGroup) {
createRepeatedGroupResponse(questionnaireItem, questionnaireResponseItem)
} else {
listOf(
questionnaireResponseItem.apply {
text = questionnaireItem.localizedTextSpanned?.toString()
// Nested group items
item = getEnabledResponseItems(questionnaireItem.item, questionnaireResponseItem.item)
// Nested question items
answer.forEach { it.item = getEnabledResponseItems(questionnaireItem.item, it.item) }
}
)
.map { (questionnaireItem, questionnaireResponseItem) ->
questionnaireResponseItem.apply {
text = questionnaireItem.localizedTextSpanned?.toString()
// Nested group items
item = getEnabledResponseItems(questionnaireItem.item, questionnaireResponseItem.item)
// Nested question items
answer.forEach { it.item = getEnabledResponseItems(questionnaireItem.item, it.item) }
}
}
.toList()
}

/**
* Repeated groups need some massaging for their returned data-format; each instance of the group
* should be flattened out to be its own item in the parent, rather than an answer to the main
* item. See discussion:
* http:https://community.fhir.org/t/questionnaire-repeating-groups-what-is-the-correct-format/2276/3
*
* For example, if the group contains 2 questions, and the user answered the group 3 times, this
* function will return a list with 3 responses; each of those responses will have the linkId of
* the provided group, and each will contain an item array with 2 items (the answers to the
* individual questions within this particular group instance).
*/
private fun createRepeatedGroupResponse(
questionnaireItem: QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponseItemComponent,
): List<QuestionnaireResponseItemComponent> {
val individualQuestions = questionnaireItem.item
return questionnaireResponseItem.answer.map { repeatedGroupInstance ->
val responsesToIndividualQuestions = repeatedGroupInstance.item
check(responsesToIndividualQuestions.size == individualQuestions.size) {
"Repeated groups responses must have the same # of responses as the group has questions"
}
QuestionnaireResponseItemComponent().apply {
linkId = questionnaireItem.linkId
text = questionnaireItem.localizedTextSpanned?.toString()
item =
getEnabledResponseItems(
questionnaireItemList = individualQuestions,
questionnaireResponseItemList = responsesToIndividualQuestions,
)
}
}
}

/**
* Gets a list of [QuestionnairePage]s for a paginated questionnaire, or `null` if the
* questionnaire is not paginated.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/*
* Copyright 2022 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http:https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.google.android.fhir.datacapture.extensions

import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse

/** Pre-order list of all questionnaire response items in the questionnaire. */
val QuestionnaireResponse.allItems: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>
get() = item.flatMap { it.descendant }

/**
* Packs repeated groups under the same questionnaire response item.
*
* Repeated groups need some massaging before the questionnaire view model can interpret them
* correctly. This is because they are flattened out and nested directly under the parent in the
* FHIR data format.
*
* More details: https://build.fhir.org/questionnaireresponse.html#link.
*
* This function should be called before the questionnaire view model accepts an
* application-provided questionnaire response.
*
* See also [unpackRepeatedGroups].
*/
internal fun QuestionnaireResponse.packRepeatedGroups() {
item = item.packRepeatedGroups()
}

private fun List<QuestionnaireResponse.QuestionnaireResponseItemComponent>.packRepeatedGroups():
List<QuestionnaireResponse.QuestionnaireResponseItemComponent> {
forEach { it ->
it.item = it.item.packRepeatedGroups()
it.answer.forEach { it.item = it.item.packRepeatedGroups() }
}
val linkIdToPackedResponseItems =
groupBy { it.linkId }
.mapValues { (linkId, questionnaireResponseItems) ->
questionnaireResponseItems.singleOrNull()
?: QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
this.linkId = linkId
answer =
questionnaireResponseItems.map {
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
item = it.item
}
}
}
}
return map { it.linkId }.distinct().map { linkIdToPackedResponseItems[it]!! }
}

/**
* Unpacks repeated groups as separate questionnaire response items under their parent.
*
* Repeated groups need some massaging for their returned data-format; each instance of the group
* should be flattened out to be its own item in the parent, rather than an answer to the main item.
*
* More details: https://build.fhir.org/questionnaireresponse.html#link.
*
* For example, if the group contains 2 questions, and the user answered the group 3 times, this
* function will return a list with 3 responses; each of those responses will have the linkId of the
* provided group, and each will contain an item array with 2 items (the answers to the individual
* questions within this particular group instance).
*
* This function should be called before returning the questionnaire response to the application.
*
* See also [packRepeatedGroups].
*/
internal fun QuestionnaireResponse.unpackRepeatedGroups(questionnaire: Questionnaire) {
item = unpackRepeatedGroups(questionnaire.item, item)
}

private fun unpackRepeatedGroups(
questionnaireItems: List<Questionnaire.QuestionnaireItemComponent>,
questionnaireResponseItems: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>
): List<QuestionnaireResponse.QuestionnaireResponseItemComponent> {
return questionnaireItems.zip(questionnaireResponseItems).flatMap {
(questionnaireItem, questionnaireResponseItem) ->
unpackRepeatedGroups(questionnaireItem, questionnaireResponseItem)
}
}

private fun unpackRepeatedGroups(
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent
): List<QuestionnaireResponse.QuestionnaireResponseItemComponent> {
questionnaireResponseItem.item =
unpackRepeatedGroups(questionnaireItem.item, questionnaireResponseItem.item)
questionnaireResponseItem.answer.forEach {
it.item = unpackRepeatedGroups(questionnaireItem.item, it.item)
}
return if (questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP &&
questionnaireItem.repeats
) {
questionnaireResponseItem.answer.map {
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
linkId = questionnaireItem.linkId
text = questionnaireItem.localizedTextSpanned?.toString()
item = it.item
}
}
} else {
listOf(questionnaireResponseItem)
}
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.validation

import android.content.Context
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
import com.google.android.fhir.datacapture.extensions.packRepeatedGroups
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type
Expand Down Expand Up @@ -199,7 +200,10 @@ object QuestionnaireResponseValidator {
) {
"Mismatching Questionnaire ${questionnaire.url} and QuestionnaireResponse (for Questionnaire ${questionnaireResponse.questionnaire})"
}
checkQuestionnaireResponseItems(questionnaire.item, questionnaireResponse.item)
checkQuestionnaireResponseItems(
questionnaire.item,
questionnaireResponse.copy().apply { packRepeatedGroups() }.item
)
}

private fun checkQuestionnaireResponseItems(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -770,7 +770,7 @@ class QuestionnaireViewModelTest {
}
val questionnaireResponse =
QuestionnaireResponse().apply {
id = "a-questionnaire"
id = "a-questionnaire-response"
addItem(
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
linkId = "a-link-id"
Expand Down Expand Up @@ -800,6 +800,65 @@ class QuestionnaireViewModelTest {
assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse)
}

@Test
fun `should not throw exception for repeated group`() {
val questionnaire =
Questionnaire().apply {
id = "a-questionnaire"
addItem(
Questionnaire.QuestionnaireItemComponent().apply {
linkId = "repeated-group"
type = Questionnaire.QuestionnaireItemType.GROUP
repeats = true
addItem(
Questionnaire.QuestionnaireItemComponent().apply {
linkId = "nested-question"
type = Questionnaire.QuestionnaireItemType.BOOLEAN
}
)
}
)
}
val questionnaireResponse =
QuestionnaireResponse().apply {
id = "a-questionnaire-response"
addItem(
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
linkId = "repeated-group"
addItem(
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
linkId = "nested-question"
addAnswer(
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
value = BooleanType(true)
}
)
}
)
}
)
addItem(
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
linkId = "repeated-group"
addItem(
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
linkId = "nested-question"
addAnswer(
QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply {
value = BooleanType(false)
}
)
}
)
}
)
}

val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse)

assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse)
}

@Test
fun `should not throw exception for non primitive type`() {
val testOption1 = Coding("test", "option", "1")
Expand Down
Loading

0 comments on commit 256ab75

Please sign in to comment.