Skip to content

Commit 4a503f0

Browse files
Merge branch 'master' into rm/review-layout-catalog-app
2 parents 7403e73 + cc692a8 commit 4a503f0

File tree

12 files changed

+765
-36
lines changed

12 files changed

+765
-36
lines changed

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/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)

datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DropDownViewHolderFactory.kt

Lines changed: 23 additions & 1 deletion
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,13 +19,17 @@ package com.google.android.fhir.datacapture.views.factories
1919
import android.content.Context
2020
import android.graphics.drawable.Drawable
2121
import android.text.Spanned
22+
import android.text.method.TextKeyListener
2223
import android.view.LayoutInflater
2324
import android.view.View
2425
import android.view.ViewGroup
26+
import android.view.inputmethod.InputMethodManager
2527
import android.widget.AdapterView
2628
import android.widget.ArrayAdapter
29+
import android.widget.ImageView
2730
import android.widget.TextView
2831
import androidx.appcompat.app.AppCompatActivity
32+
import androidx.core.view.doOnNextLayout
2933
import androidx.lifecycle.lifecycleScope
3034
import com.google.android.fhir.datacapture.R
3135
import com.google.android.fhir.datacapture.extensions.displayString
@@ -52,14 +56,28 @@ internal object DropDownViewHolderFactory :
5256
private lateinit var header: HeaderView
5357
private lateinit var textInputLayout: TextInputLayout
5458
private lateinit var autoCompleteTextView: MaterialAutoCompleteTextView
59+
private lateinit var clearInputIcon: ImageView
5560
override lateinit var questionnaireViewItem: QuestionnaireViewItem
5661
private lateinit var context: AppCompatActivity
5762

5863
override fun init(itemView: View) {
5964
header = itemView.findViewById(R.id.header)
6065
textInputLayout = itemView.findViewById(R.id.text_input_layout)
6166
autoCompleteTextView = itemView.findViewById(R.id.auto_complete)
67+
clearInputIcon = itemView.findViewById(R.id.clear_input_icon)
6268
context = itemView.context.tryUnwrapContext()!!
69+
autoCompleteTextView.setOnFocusChangeListener { view, hasFocus ->
70+
if (!hasFocus) {
71+
(view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
72+
.hideSoftInputFromWindow(view.windowToken, 0)
73+
}
74+
}
75+
clearInputIcon.setOnClickListener {
76+
context.lifecycleScope.launch {
77+
questionnaireViewItem.clearAnswer()
78+
autoCompleteTextView.doOnNextLayout { autoCompleteTextView.showDropDown() }
79+
}
80+
}
6381
}
6482

6583
override fun bind(questionnaireViewItem: QuestionnaireViewItem) {
@@ -130,6 +148,10 @@ internal object DropDownViewHolderFactory :
130148
}
131149
}
132150
}
151+
val isEditable = questionnaireViewItem.answers.isEmpty()
152+
if (!isEditable) autoCompleteTextView.clearFocus()
153+
autoCompleteTextView.keyListener = if (isEditable) TextKeyListener.getInstance() else null
154+
clearInputIcon.visibility = if (isEditable) View.GONE else View.VISIBLE
133155

134156
displayValidationResult(questionnaireViewItem.validationResult)
135157
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<vector
2+
xmlns:android="http://schemas.android.com/apk/res/android"
3+
android:width="24dp"
4+
android:height="24dp"
5+
android:viewportWidth="24"
6+
android:viewportHeight="24"
7+
android:tint="?attr/colorControlNormal"
8+
>
9+
<path
10+
android:fillColor="@android:color/white"
11+
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"
12+
/>
13+
</vector>

0 commit comments

Comments
 (0)