Skip to content

Commit 2c14713

Browse files
committed
Merge remote-tracking branch 'origin/master' into feature/add-custom-callback-for-autocomplete
2 parents 9f1ddf8 + 480fd79 commit 2c14713

File tree

19 files changed

+824
-54
lines changed

19 files changed

+824
-54
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ concurrency:
4444
jobs:
4545
# Build will compile APK, test APK and run tests, lint, etc.
4646
build:
47-
runs-on: ubuntu-22.04-8core
47+
runs-on: 'ubuntu-24.04-8core'
4848
timeout-minutes: 60
4949
permissions:
5050
actions: read

catalog/src/main/assets/component_auto_complete.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@
8282
},
8383
{
8484
"valueCoding": {
85-
"code": "hypertension",
85+
"code": "hbp",
8686
"display": "High Blood Pressure"
8787
}
8888
},

catalog/src/main/assets/component_auto_complete_with_validation.json

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@
77
"type": "choice",
88
"repeats": true,
99
"required": true,
10+
"item": [
11+
{
12+
"extension": [
13+
{
14+
"url": "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory",
15+
"valueCodeableConcept": {
16+
"coding": [
17+
{
18+
"system": "http://hl7.org/fhir/questionnaire-display-category",
19+
"code": "instructions"
20+
}
21+
]
22+
}
23+
}
24+
],
25+
"linkId": "instruction",
26+
"text": "Try typing Asthma, Chronic Lung Disease, Depression, Diabetes, Hypertension, High Blood Pressure, or High Cholesterol",
27+
"type": "display"
28+
}
29+
],
1030
"extension": [
1131
{
1232
"url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl",
@@ -27,7 +47,8 @@
2747
"valueCoding": {
2848
"code": "asthma",
2949
"display": "Asthma"
30-
}
50+
},
51+
"initialSelected": true
3152
},
3253
{
3354
"valueCoding": {
@@ -55,7 +76,7 @@
5576
},
5677
{
5778
"valueCoding": {
58-
"code": "hypertension",
79+
"code": "hbp",
5980
"display": "High Blood Pressure"
6081
}
6182
},
@@ -65,13 +86,7 @@
6586
"display": "High Cholesterol"
6687
}
6788
}
68-
],
69-
"initial": {
70-
"valueCoding": {
71-
"code": "asthma",
72-
"display": "Asthma"
73-
}
74-
}
89+
]
7590
}
7691
]
7792
}

catalog/src/main/assets/component_help.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
}
5252
}
5353
],
54-
"linkId": "1.3",
54+
"linkId": "1.1",
5555
"text": "Select one",
5656
"type": "display"
5757
},
@@ -69,7 +69,7 @@
6969
}
7070
}
7171
],
72-
"linkId": "1.3",
72+
"linkId": "1.2",
7373
"text": "Only one answer can be selected. If none apply, skip the question.",
7474
"type": "display"
7575
}

datacapture/src/androidTest/java/com/google/android/fhir/datacapture/test/views/DropDownViewHolderFactoryEspressoTest.kt

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023 Google LLC
2+
* Copyright 2023-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -22,6 +22,7 @@ import android.widget.FrameLayout
2222
import android.widget.TextView
2323
import androidx.test.espresso.Espresso.onData
2424
import androidx.test.espresso.Espresso.onView
25+
import androidx.test.espresso.PerformException
2526
import androidx.test.espresso.action.ViewActions.click
2627
import androidx.test.espresso.action.ViewActions.typeText
2728
import androidx.test.espresso.assertion.ViewAssertions.matches
@@ -51,6 +52,7 @@ import org.hl7.fhir.r4.model.Questionnaire
5152
import org.hl7.fhir.r4.model.QuestionnaireResponse
5253
import org.hl7.fhir.r4.model.Reference
5354
import org.hl7.fhir.r4.model.StringType
55+
import org.junit.Assert.assertThrows
5456
import org.junit.Before
5557
import org.junit.Rule
5658
import org.junit.Test
@@ -274,6 +276,81 @@ class DropDownViewHolderFactoryEspressoTest {
274276
.isEqualTo(3)
275277
}
276278

279+
@Test
280+
fun shouldPreventTypingWhenAnswerIsSelectedInAutoCompleteDropdown() {
281+
val preselectedAnswer =
282+
QuestionnaireResponse.QuestionnaireResponseItemComponent().apply {
283+
addAnswer().value = StringType("Coding 1")
284+
}
285+
286+
val questionnaireItem =
287+
QuestionnaireViewItem(
288+
createAnswerOptions("Coding 1", "Coding 2", "Coding 3"),
289+
preselectedAnswer,
290+
validationResult = NotValidated,
291+
answersChangedCallback = { _, _, _, _ -> },
292+
)
293+
294+
val autoComplete = viewHolder.itemView.findViewById<AutoCompleteTextView>(R.id.auto_complete)
295+
296+
runOnUI {
297+
viewHolder.bind(questionnaireItem)
298+
autoComplete.showDropDown()
299+
}
300+
301+
assertThrows(PerformException::class.java) {
302+
onView(withId(R.id.auto_complete)).perform(typeText("new text"))
303+
}
304+
305+
assertThat(autoComplete.text.toString()).isEqualTo("Coding 1")
306+
}
307+
308+
@Test
309+
fun shouldSelectAndClearAnswerInAutoCompleteDropdown() {
310+
var selectedAnswers: List<QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent>? =
311+
null
312+
val answerOptions = listOf("Coding 1", "Coding 2", "Coding 3")
313+
314+
var questionnaireItem =
315+
QuestionnaireViewItem(
316+
createAnswerOptions(*answerOptions.toTypedArray()),
317+
responseValueStringOptions(),
318+
validationResult = NotValidated,
319+
answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers },
320+
)
321+
322+
val autoComplete = viewHolder.itemView.findViewById<AutoCompleteTextView>(R.id.auto_complete)
323+
324+
runOnUI {
325+
viewHolder.bind(questionnaireItem)
326+
autoComplete.showDropDown()
327+
}
328+
329+
// Test selection flow
330+
onView(withText("Coding 1"))
331+
.inRoot(isPlatformPopup())
332+
.check(matches(isDisplayed()))
333+
.perform(click())
334+
335+
assertThat(selectedAnswers).hasSize(1)
336+
assertThat((selectedAnswers!!.first().value as StringType).valueAsString).isEqualTo("Coding 1")
337+
338+
// Test clearing flow
339+
questionnaireItem =
340+
QuestionnaireViewItem(
341+
createAnswerOptions(*answerOptions.toTypedArray()),
342+
responseValueStringOptions().apply { answer = selectedAnswers },
343+
validationResult = NotValidated,
344+
answersChangedCallback = { _, _, answers, _ -> selectedAnswers = answers },
345+
)
346+
347+
runOnUI { viewHolder.bind(questionnaireItem) }
348+
349+
onView(withId(R.id.clear_input_icon)).perform(click())
350+
351+
assertThat(selectedAnswers).isEmpty()
352+
}
353+
277354
@Test
278355
fun shouldReturnFilteredWithNoResultsDropDownMenuItems() {
279356
val questionnaireViewItem =

datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireFragment.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 Google LLC
2+
* Copyright 2023-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -21,6 +21,7 @@ import android.os.Bundle
2121
import android.view.LayoutInflater
2222
import android.view.View
2323
import android.view.ViewGroup
24+
import android.widget.TextView
2425
import androidx.annotation.VisibleForTesting
2526
import androidx.appcompat.view.ContextThemeWrapper
2627
import androidx.core.content.res.use
@@ -94,6 +95,7 @@ class QuestionnaireFragment : Fragment() {
9495
view.findViewById<RecyclerView>(R.id.questionnaire_edit_recycler_view)
9596
val questionnaireReviewRecyclerView =
9697
view.findViewById<RecyclerView>(R.id.questionnaire_review_recycler_view)
98+
val questionnaireTitle = view.findViewById<TextView>(R.id.questionnaire_title)
9799

98100
// This container frame floats at the bottom of the view to make navigation controls visible at
99101
// all times when the user scrolls. Use
@@ -170,6 +172,8 @@ class QuestionnaireFragment : Fragment() {
170172
} else {
171173
View.GONE
172174
}
175+
questionnaireTitle.visibility = View.VISIBLE
176+
questionnaireTitle.text = getString(R.string.questionnaire_review_mode_title)
173177

174178
// Set bottom navigation
175179
if (state.bottomNavItem != null) {
@@ -189,6 +193,7 @@ class QuestionnaireFragment : Fragment() {
189193
questionnaireEditAdapter.submitList(state.items)
190194
questionnaireEditRecyclerView.visibility = View.VISIBLE
191195
reviewModeEditButton.visibility = View.GONE
196+
questionnaireTitle.visibility = View.GONE
192197

193198
// Set bottom navigation
194199
if (state.bottomNavItem != null) {

datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireValidationErrorMessageDialogFragment.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2023-2024 Google LLC
2+
* Copyright 2023-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -32,6 +32,7 @@ import androidx.lifecycle.ViewModel
3232
import androidx.lifecycle.ViewModelProvider
3333
import com.google.android.fhir.datacapture.extensions.flattened
3434
import com.google.android.fhir.datacapture.extensions.localizedFlyoverSpanned
35+
import com.google.android.fhir.datacapture.extensions.toSpanned
3536
import com.google.android.fhir.datacapture.validation.Invalid
3637
import com.google.android.fhir.datacapture.validation.ValidationResult
3738
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -90,9 +91,12 @@ internal class QuestionnaireValidationErrorMessageDialogFragment(
9091
val viewModel: QuestionnaireValidationErrorViewModel by
9192
activityViewModels(factoryProducer = factoryProducer)
9293
text =
93-
viewModel.getItemsTextWithValidationErrors().joinToString(separator = "\n") {
94-
context.getString(R.string.questionnaire_validation_error_item_text_with_bullet, it)
95-
}
94+
viewModel
95+
.getItemsTextWithValidationErrors()
96+
.joinToString(separator = "\n") {
97+
context.getString(R.string.questionnaire_validation_error_item_text_with_bullet, it)
98+
}
99+
.toSpanned()
96100
}
97101
}
98102
}

datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt

Lines changed: 50 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2022-2024 Google LLC
2+
* Copyright 2022-2025 Google LLC
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.mapping
1919
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
2020
import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension
2121
import com.google.android.fhir.datacapture.extensions.initialExpression
22+
import com.google.android.fhir.datacapture.extensions.initialSelected
2223
import com.google.android.fhir.datacapture.extensions.logicalId
2324
import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts
2425
import com.google.android.fhir.datacapture.extensions.targetStructureMap
@@ -248,22 +249,59 @@ object ResourceMapper {
248249
"QuestionnaireItem item is not allowed to have both initial.value and initial expression. See rule at http://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression."
249250
}
250251

252+
// Initial values can't be specified for groups or display items
253+
check(
254+
!(questionnaireItem.type == Questionnaire.QuestionnaireItemType.GROUP ||
255+
questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY) ||
256+
(questionnaireItem.initial.isEmpty() && questionnaireItem.initialExpression == null),
257+
) {
258+
"QuestionnaireItem item is not allowed to have initial value or initial expression for groups or display items. See rule at http://build.fhir.org/ig/HL7/sdc/expressions.html#initialExpression."
259+
}
260+
251261
questionnaireItem.initialExpression
252262
?.let {
253263
evaluateToBase(
254-
questionnaireResponse = null,
255-
questionnaireResponseItem = null,
256-
expression = it.expression,
257-
contextMap = launchContexts,
258-
)
259-
.firstOrNull()
264+
questionnaireResponse = null,
265+
questionnaireResponseItem = null,
266+
expression = it.expression,
267+
contextMap = launchContexts,
268+
)
260269
}
261270
?.let {
262-
// Set initial value for the questionnaire item. Questionnaire items should not have both
263-
// initial value and initial expression.
264-
val value = it.asExpectedType(questionnaireItem.type)
265-
questionnaireItem.initial =
266-
mutableListOf(Questionnaire.QuestionnaireItemInitialComponent().setValue(value))
271+
// Set initial value for the questionnaire item.
272+
if (it.isEmpty()) return@let
273+
274+
// If questionnaireItem.repeats is false only first value is selected from initialExpression
275+
// result set
276+
val evaluatedExpressionResult =
277+
if (questionnaireItem.repeats) {
278+
it.map { it.asExpectedType(questionnaireItem.type) }
279+
} else {
280+
listOf(it.first().asExpectedType(questionnaireItem.type))
281+
}
282+
283+
/**
284+
* For answer options, the initialSelected extension is used to highlight initial values.
285+
*
286+
* Note: If the initial expression evaluates to five values (1, 2, 3, 4, 5) but only three
287+
* answer options (1, 2, 3) exist, then 4 and 5 will be ignored. These values are not added
288+
* as additional options, nor would it make sense to do so. This behavior ensures the answer
289+
* options remain consistent with the defined set.
290+
*/
291+
if (questionnaireItem.answerOption.isNotEmpty()) {
292+
questionnaireItem.answerOption.forEach { answerOption ->
293+
answerOption.initialSelected =
294+
evaluatedExpressionResult.any { answerOption.value.equalsDeep(it) }
295+
}
296+
} else {
297+
questionnaireItem.initial =
298+
evaluatedExpressionResult.map {
299+
Questionnaire.QuestionnaireItemInitialComponent()
300+
.setValue(
301+
it,
302+
)
303+
}
304+
}
267305
}
268306

269307
populateInitialValues(questionnaireItem.item, launchContexts)

0 commit comments

Comments
 (0)