Skip to content

Commit

Permalink
Repeated Group Headers (google#1994)
Browse files Browse the repository at this point in the history
* Repeated Group Headers

* spotless check

* review comments

* isRepeatedGroup unit test

* review comment.

* repeated group addition and deletion android test.

* review comments.

* Add readoly attribut support to repeated group item.

* unit test for remove answer at index .

* remove duplicate test.

* update test.

* android tests

* update response.json

---------

Co-authored-by: Santosh Pingle <[email protected]>
Co-authored-by: santosh-pingle <[email protected]>
  • Loading branch information
3 people authored Jun 10, 2024
1 parent 69e87d7 commit 1016558
Show file tree
Hide file tree
Showing 19 changed files with 504 additions and 45 deletions.
24 changes: 24 additions & 0 deletions datacapture/sampledata/component_repeated_group.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"resourceType": "Questionnaire",
"item": [
{
"linkId": "1",
"type": "group",
"text": "Repeated Group",
"repeats": true,
"item": [
{
"linkId": "1-1",
"text": "Sample date question",
"type": "date",
"extension": [
{
"url": "http://hl7.org/fhir/StructureDefinition/entryFormat",
"valueString": "yyyy-mm-dd"
}
]
}
]
}
]
}
20 changes: 20 additions & 0 deletions datacapture/sampledata/repeated_group_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"resourceType": "QuestionnaireResponse",
"item": [
{
"linkId": "1",
"text": "Repeated Group",
"item": [
{
"linkId": "1-1",
"text": "Sample date question",
"answer": [
{
"valueDate": "2024-06-03"
}
]
}
]
}
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class PhoneNumberViewHolderFactoryInstrumentedTest {
}

@Test
fun createViewHolder_shouldReturn_phoneNumberViewHolder() {
fun createViewHolder_phoneNumberViewHolderFactory_returnsViewHolder() {
val viewHolderFromAdapter =
questionnaireEditAdapter.createViewHolder(
parent,
Expand All @@ -74,8 +74,13 @@ class PhoneNumberViewHolderFactoryInstrumentedTest {
subtype = QuestionnaireViewHolderType.PHONE_NUMBER.value,
)
.viewType,
) as QuestionnaireEditAdapter.ViewHolder.QuestionHolder
assertThat(
viewHolderFromAdapter.holder.itemView
.findViewById<TextInputEditText>(R.id.text_input_edit_text)
.visibility,
)
assertThat(viewHolderFromAdapter).isInstanceOf(viewHolder::class.java)
.isEqualTo(View.VISIBLE)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,18 @@ import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.fragment.app.commitNow
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.UiController
import androidx.test.espresso.ViewAction
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.typeText
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.RootMatchers
import androidx.test.espresso.matcher.ViewMatchers
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
Expand Down Expand Up @@ -604,24 +610,109 @@ class QuestionnaireUiEspressoTest {
}
}

@Test
fun test_repeated_group_is_added() {
buildFragmentFromQuestionnaire("/component_repeated_group.json")

onView(withId(R.id.questionnaire_edit_recycler_view))
.perform(
RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(
0,
clickChildViewWithId(R.id.add_item),
),
)

onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check {
view,
noViewFoundException,
->
if (noViewFoundException != null) {
throw noViewFoundException
}
assertThat(
(view as RecyclerView).countChildViewOccurrences(
R.id.repeated_group_instance_header_title,
),
)
.isEqualTo(1)
}
}

@Test
fun test_repeated_group_is_deleted() {
buildFragmentFromQuestionnaire(
"/component_repeated_group.json",
responseFileName = "/repeated_group_response.json",
)

onView(withId(R.id.questionnaire_edit_recycler_view))
.perform(
RecyclerViewActions.actionOnItemAtPosition<ViewHolder>(
1,
clickChildViewWithId(R.id.repeated_group_instance_header_delete_button),
),
)

onView(ViewMatchers.withId(R.id.questionnaire_edit_recycler_view)).check {
view,
noViewFoundException,
->
if (noViewFoundException != null) {
throw noViewFoundException
}
assertThat(
(view as RecyclerView).countChildViewOccurrences(
R.id.repeated_group_instance_header_title,
),
)
.isEqualTo(0)
}
}

private fun RecyclerView.countChildViewOccurrences(viewId: Int): Int {
var count = 0
for (i in 0 until this.adapter!!.itemCount) {
val holder = findViewHolderForAdapterPosition(i)
if (holder?.itemView?.findViewById<View>(viewId) != null) {
count++
}
}
return count
}

private fun clickChildViewWithId(id: Int) =
object : ViewAction {
override fun getConstraints() = isAssignableFrom(View::class.java)

override fun getDescription() = "Click on a child view with specified id."

override fun perform(uiController: UiController?, view: View) {
view.findViewById<View>(id)?.performClick()
}
}

private fun buildFragmentFromQuestionnaire(
fileName: String,
isReviewMode: Boolean = false,
responseFileName: String? = null,
): QuestionnaireFragment {
val questionnaireJsonString = readFileFromAssets(fileName)
val questionnaireFragment =
val builder =
QuestionnaireFragment.builder()
.setQuestionnaire(questionnaireJsonString)
.setShowCancelButton(true)
.showReviewPageBeforeSubmit(isReviewMode)
.build()
activityScenarioRule.scenario.onActivity { activity ->
activity.supportFragmentManager.commitNow {
setReorderingAllowed(true)
add(R.id.container_holder, questionnaireFragment)

responseFileName?.let { builder.setQuestionnaireResponse(readFileFromAssets(it)) }

return builder.build().also { fragment ->
activityScenarioRule.scenario.onActivity { activity ->
activity.supportFragmentManager.commitNow {
setReorderingAllowed(true)
add(R.id.container_holder, fragment)
}
}
}
return questionnaireFragment
}

private fun buildFragmentFromQuestionnaire(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Google LLC
* Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -17,9 +17,20 @@
package com.google.android.fhir.datacapture

import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import org.hl7.fhir.r4.model.QuestionnaireResponse

/** Various types of rows that can be used in a Questionnaire RecyclerView. */
internal sealed interface QuestionnaireAdapterItem {
/** A row for a question in a Questionnaire RecyclerView. */
data class Question(val item: QuestionnaireViewItem) : QuestionnaireAdapterItem

/** A row for a repeated group response instance's header. */
data class RepeatedGroupHeader(
/** The response index. This is 0-indexed, but should be 1-indexed when rendered in the UI. */
val index: Int,
/** Callback that is invoked when the user clicks the delete button. */
val onDeleteClicked: () -> Unit,
/** Responses nested under this header. */
val responses: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>,
) : QuestionnaireAdapterItem
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022-2023 Google LLC
* Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -16,10 +16,14 @@

package com.google.android.fhir.datacapture

import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.google.android.fhir.datacapture.contrib.views.PhoneNumberViewHolderFactory
import com.google.android.fhir.datacapture.extensions.inflate
import com.google.android.fhir.datacapture.extensions.itemControl
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import com.google.android.fhir.datacapture.views.factories.AttachmentViewHolderFactory
Expand All @@ -46,16 +50,23 @@ internal class QuestionnaireEditAdapter(
private val questionnaireItemViewHolderMatchers:
List<QuestionnaireFragment.QuestionnaireItemViewHolderFactoryMatcher> =
emptyList(),
) : ListAdapter<QuestionnaireAdapterItem, QuestionnaireItemViewHolder>(DiffCallbacks.ITEMS) {
) :
ListAdapter<QuestionnaireAdapterItem, QuestionnaireEditAdapter.ViewHolder>(DiffCallbacks.ITEMS) {
/**
* @param viewType the integer value of the [QuestionnaireViewHolderType] used to render the
* [QuestionnaireViewItem].
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QuestionnaireItemViewHolder {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val typedViewType = ViewType.parse(viewType)
val subtype = typedViewType.subtype
return when (typedViewType.type) {
ViewType.Type.QUESTION -> onCreateViewHolderQuestion(parent = parent, subtype = subtype)
ViewType.Type.QUESTION ->
ViewHolder.QuestionHolder(onCreateViewHolderQuestion(parent = parent, subtype = subtype))
ViewType.Type.REPEATED_GROUP_HEADER -> {
ViewHolder.RepeatedGroupHeaderHolder(
parent.inflate(R.layout.repeated_group_instance_header_view),
)
}
}
}

Expand Down Expand Up @@ -99,10 +110,16 @@ internal class QuestionnaireEditAdapter(
return viewHolderFactory.create(parent)
}

override fun onBindViewHolder(holder: QuestionnaireItemViewHolder, position: Int) {
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
when (val item = getItem(position)) {
is QuestionnaireAdapterItem.Question -> {
holder.bind(item.item)
holder as ViewHolder.QuestionHolder
holder.holder.bind(item.item)
}
is QuestionnaireAdapterItem.RepeatedGroupHeader -> {
holder as ViewHolder.RepeatedGroupHeaderHolder
holder.header.text = "Group ${item.index + 1}"
holder.delete.setOnClickListener { item.onDeleteClicked() }
}
}
}
Expand All @@ -120,6 +137,11 @@ internal class QuestionnaireEditAdapter(
type = ViewType.Type.QUESTION
subtype = getItemViewTypeForQuestion(item.item)
}
is QuestionnaireAdapterItem.RepeatedGroupHeader -> {
type = ViewType.Type.REPEATED_GROUP_HEADER
// All of the repeated group headers will be rendered identically
subtype = 0
}
}
return ViewType.from(type = type, subtype = subtype).viewType
}
Expand Down Expand Up @@ -150,6 +172,7 @@ internal class QuestionnaireEditAdapter(

enum class Type {
QUESTION,
REPEATED_GROUP_HEADER,
}
}

Expand Down Expand Up @@ -240,6 +263,15 @@ internal class QuestionnaireEditAdapter(
?: QuestionnaireViewHolderType.EDIT_TEXT_SINGLE_LINE
}

internal sealed class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
class QuestionHolder(val holder: QuestionnaireItemViewHolder) : ViewHolder(holder.itemView)

class RepeatedGroupHeaderHolder(itemView: View) : ViewHolder(itemView) {
val header: TextView = itemView.findViewById(R.id.repeated_group_instance_header_title)
val delete: View = itemView.findViewById(R.id.repeated_group_instance_header_delete_button)
}
}

internal companion object {
// Choice questions are rendered as dialogs if they have at least this many options
const val MINIMUM_NUMBER_OF_ANSWER_OPTIONS_FOR_DIALOG = 10
Expand All @@ -261,6 +293,10 @@ internal object DiffCallbacks {
newItem is QuestionnaireAdapterItem.Question &&
QUESTIONS.areItemsTheSame(oldItem, newItem)
}
is QuestionnaireAdapterItem.RepeatedGroupHeader -> {
newItem is QuestionnaireAdapterItem.RepeatedGroupHeader &&
oldItem.index == newItem.index
}
}

override fun areContentsTheSame(
Expand All @@ -272,6 +308,10 @@ internal object DiffCallbacks {
newItem is QuestionnaireAdapterItem.Question &&
QUESTIONS.areContentsTheSame(oldItem, newItem)
}
is QuestionnaireAdapterItem.RepeatedGroupHeader -> {
newItem is QuestionnaireAdapterItem.RepeatedGroupHeader &&
oldItem.responses == newItem.responses
}
}
}

Expand Down
Loading

0 comments on commit 1016558

Please sign in to comment.