diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt index e8ad47b1a..c1cd7ee39 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -78,9 +78,8 @@ fun Main( modifier = Modifier .background(SurfaceColor.Container), ) { - val screenDropdownItemList = mutableListOf() - Groups.entries.forEach { - screenDropdownItemList.add(DropdownItem(it.label)) + var screenDropdownItemList by remember { + mutableStateOf(emptyList()) } if (isComponentSelected) { @@ -91,14 +90,30 @@ fun Main( top = Spacing.Spacing16, ), title = "Group", - dropdownItems = screenDropdownItemList.toList(), - onItemSelected = { currentScreen.value = getCurrentScreen(it.label) }, + fetchItem = { index -> screenDropdownItemList[index] }, + itemCount = screenDropdownItemList.size, + onSearchOption = { query -> + screenDropdownItemList = if (query.isNotEmpty()) { + screenDropdownItemList.filter { it.label.contains(query) } + } else { + Groups.entries.map { + DropdownItem(it.label) + } + } + }, + useDropDown = false, + onItemSelected = { _, item -> currentScreen.value = getCurrentScreen(item.label) }, onResetButtonClicked = { currentScreen.value = Groups.NO_GROUP_SELECTED }, state = InputShellState.UNFOCUSED, expanded = true, selectedItem = DropdownItem(currentScreen.value.label), inputStyle = InputStyle.DataInputStyle() .apply { backGroundColor = SurfaceColor.SurfaceBright }, + loadOptions = { + screenDropdownItemList = Groups.entries.map { + DropdownItem(it.label) + } + }, ) when (currentScreen.value) { diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/components/GroupComponentDropDown.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/components/GroupComponentDropDown.kt index 66bb7cba0..480b59d88 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/components/GroupComponentDropDown.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/components/GroupComponentDropDown.kt @@ -21,11 +21,18 @@ fun GroupComponentDropDown( InputDropDown( modifier = modifier.padding(horizontal = Spacing.Spacing16), title = "Component", - dropdownItems = dropdownItems, - onItemSelected = onItemSelected, + itemCount = dropdownItems.size, + onSearchOption = {}, + fetchItem = { index -> dropdownItems[index] }, + onItemSelected = { _, item -> onItemSelected(item) }, onResetButtonClicked = onResetButtonClicked, selectedItem = selectedItem, state = InputShellState.UNFOCUSED, - inputStyle = InputStyle.DataInputStyle().apply { backGroundColor = SurfaceColor.SurfaceBright }, + useDropDown = false, + inputStyle = InputStyle.DataInputStyle() + .apply { backGroundColor = SurfaceColor.SurfaceBright }, + loadOptions = { + /*no-op*/ + }, ) } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt index 224075a9e..c834aadca 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/parameter/ParameterSelectorScreen.kt @@ -233,12 +233,16 @@ fun ParameterSelectorScreen() { title = "DropDown parameter", state = InputShellState.UNFOCUSED, inputStyle = InputStyle.ParameterInputStyle(), - dropdownItems = listOf( - DropdownItem("Item 1"), - DropdownItem("Item 2"), - ), - onItemSelected = {}, + itemCount = 2, + onSearchOption = {}, + fetchItem = { index -> + DropdownItem("Item $index") + }, + onItemSelected = { _, _ -> }, onResetButtonClicked = {}, + loadOptions = { + /*no-op*/ + }, ) }, onExpand = {}, diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/toggleableInputs/InputDropDownScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/toggleableInputs/InputDropDownScreen.kt index 099ccfa21..932cc51db 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/toggleableInputs/InputDropDownScreen.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/toggleableInputs/InputDropDownScreen.kt @@ -30,64 +30,88 @@ fun InputDropDownScreen() { ) var selectedItem by remember { mutableStateOf(null) } - ColumnComponentContainer("Basic Input Dropdown with < 7 inputs") { + ColumnComponentContainer("Basic Input Dropdown") { InputDropDown( title = "Label", state = InputShellState.UNFOCUSED, - dropdownItems = options.take(6), + itemCount = options.size, + onSearchOption = {}, + fetchItem = { index -> options[index] }, onResetButtonClicked = { selectedItem = null }, - onItemSelected = { - selectedItem = it + onItemSelected = { _, item -> + selectedItem = item }, selectedItem = selectedItem, + loadOptions = { + /*no-op*/ + }, ) InputDropDown( title = "Label - With supporting text", state = InputShellState.UNFOCUSED, - dropdownItems = options.take(6), + itemCount = options.size, + onSearchOption = {}, + fetchItem = { index -> options[index] }, onResetButtonClicked = { selectedItem = null }, - onItemSelected = { - selectedItem = it + onItemSelected = { _, item -> + selectedItem = item }, selectedItem = selectedItem, supportingTextData = listOf( SupportingTextData(text = "Options"), ), + loadOptions = { + /*no-op*/ + }, ) InputDropDown( title = "Label - Parameter Style", inputStyle = InputStyle.ParameterInputStyle(), state = InputShellState.UNFOCUSED, - dropdownItems = options.take(6), + itemCount = options.size, + onSearchOption = {}, + fetchItem = { index -> options[index] }, onResetButtonClicked = { selectedItem = null }, - onItemSelected = { - selectedItem = it + onItemSelected = { _, item -> + selectedItem = item }, selectedItem = selectedItem, + loadOptions = { + /*no-op*/ + }, ) } - ColumnComponentContainer("Basic Input Dropdown with >= 7 inputs") { + ColumnComponentContainer("Basic Input Dropdown for large set") { var selectedItem4 by remember { mutableStateOf(null) } + var filteredItems by remember { mutableStateOf(options) } InputDropDown( title = "Label", state = InputShellState.UNFOCUSED, - dropdownItems = options, + itemCount = filteredItems.size, + onSearchOption = { query -> + filteredItems = options.filter { it.label.contains(query) } + }, + fetchItem = { index -> filteredItems[index] }, + useDropDown = false, onResetButtonClicked = { selectedItem4 = null }, - onItemSelected = { - selectedItem4 = it + onItemSelected = { _, item -> + selectedItem4 = item }, selectedItem = selectedItem4, + loadOptions = { + /*no-op*/ + }, ) } @@ -96,14 +120,20 @@ fun InputDropDownScreen() { InputDropDown( title = "Label", state = InputShellState.UNFOCUSED, - dropdownItems = options, + itemCount = options.size, + onSearchOption = {}, + fetchItem = { index -> options[index] }, + useDropDown = false, onResetButtonClicked = { selectedItem1 = null }, - onItemSelected = { - selectedItem1 = it + onItemSelected = { _, item -> + selectedItem1 = item }, selectedItem = selectedItem1, + loadOptions = { + /*no-op*/ + }, ) } @@ -112,14 +142,20 @@ fun InputDropDownScreen() { InputDropDown( title = "Label", state = InputShellState.ERROR, - dropdownItems = options, + itemCount = options.size, + onSearchOption = {}, + fetchItem = { index -> options[index] }, + useDropDown = false, onResetButtonClicked = { selectedItem2 = null }, - onItemSelected = { - selectedItem2 = it + onItemSelected = { _, item -> + selectedItem2 = item }, selectedItem = selectedItem2, + loadOptions = { + /*no-op*/ + }, ) } @@ -128,14 +164,20 @@ fun InputDropDownScreen() { InputDropDown( title = "Label", state = InputShellState.DISABLED, - dropdownItems = options, + itemCount = options.size, + onSearchOption = {}, + fetchItem = { index -> options[index] }, + useDropDown = false, onResetButtonClicked = { selectedItem3 = null }, - onItemSelected = { - selectedItem3 = it + onItemSelected = { _, item -> + selectedItem3 = item }, selectedItem = selectedItem3, + loadOptions = { + /*no-op*/ + }, ) } @@ -148,14 +190,20 @@ fun InputDropDownScreen() { InputDropDown( title = "Label", state = InputShellState.UNFOCUSED, - dropdownItems = dropdownItems, + itemCount = dropdownItems.size, + onSearchOption = {}, + fetchItem = { index -> options[index] }, + useDropDown = false, onResetButtonClicked = { selectedItem = null }, - onItemSelected = { - selectedItem = it + onItemSelected = { _, item -> + selectedItem = item }, selectedItem = selectedItem, + loadOptions = { + /*no-op*/ + }, ) } } diff --git a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputDropDownSnapshotTest.kt b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputDropDownSnapshotTest.kt index e45fb0ee0..370446660 100644 --- a/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputDropDownSnapshotTest.kt +++ b/designsystem/src/androidUnitTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/InputDropDownSnapshotTest.kt @@ -45,7 +45,10 @@ class InputDropDownSnapshotTest { ) Title("Input Dropdown", textColor = TextColor.OnSurfaceVariant) - SubTitle("Basic Input Dropdown with < 7 inputs", textColor = TextColor.OnSurfaceVariant) + SubTitle( + "Basic Input Dropdown with < 7 inputs", + textColor = TextColor.OnSurfaceVariant, + ) var selectedItem by remember { mutableStateOf(null) } val focusRequester = remember { FocusRequester() } @@ -53,15 +56,24 @@ class InputDropDownSnapshotTest { focusRequester.requestFocus() } + val sixOptions = options.take(6) + InputDropDown( modifier = Modifier.focusRequester(focusRequester), title = "Label", state = InputShellState.FOCUSED, - dropdownItems = options.take(6), + itemCount = sixOptions.size, + fetchItem = { index -> + sixOptions[index] + }, + onSearchOption = { + }, + loadOptions = { + }, onResetButtonClicked = { selectedItem = null }, - onItemSelected = { + onItemSelected = { _, it -> selectedItem = it }, selectedItem = selectedItem, @@ -71,11 +83,18 @@ class InputDropDownSnapshotTest { title = "Label - Parameter Style", inputStyle = InputStyle.ParameterInputStyle(), state = InputShellState.UNFOCUSED, - dropdownItems = options.take(6), + itemCount = sixOptions.size, + fetchItem = { index -> + sixOptions[index] + }, + onSearchOption = { + }, + loadOptions = { + }, onResetButtonClicked = { selectedItem = null }, - onItemSelected = { + onItemSelected = { _, it -> selectedItem = it }, selectedItem = selectedItem, @@ -83,16 +102,27 @@ class InputDropDownSnapshotTest { Spacer(Modifier.size(Spacing.Spacing18)) - SubTitle("Basic Input Dropdown with >= 7 inputs", textColor = TextColor.OnSurfaceVariant) + SubTitle( + "Basic Input Dropdown with >= 7 inputs", + textColor = TextColor.OnSurfaceVariant, + ) + var selectedItem4 by remember { mutableStateOf(null) } InputDropDown( title = "Label", state = InputShellState.UNFOCUSED, - dropdownItems = options, + itemCount = options.size, + fetchItem = { index -> + options[index] + }, + onSearchOption = { + }, + loadOptions = { + }, onResetButtonClicked = { selectedItem4 = null }, - onItemSelected = { + onItemSelected = { _, it -> selectedItem4 = it }, selectedItem = selectedItem4, @@ -100,16 +130,26 @@ class InputDropDownSnapshotTest { Spacer(Modifier.size(Spacing.Spacing18)) - SubTitle("Basic Input Dropdown with content ", textColor = TextColor.OnSurfaceVariant) + SubTitle( + "Basic Input Dropdown with content ", + textColor = TextColor.OnSurfaceVariant, + ) var selectedItem1 by remember { mutableStateOf(options[0]) } InputDropDown( title = "Label", state = InputShellState.UNFOCUSED, - dropdownItems = options, + itemCount = options.size, + fetchItem = { index -> + options[index] + }, + onSearchOption = { + }, + loadOptions = { + }, onResetButtonClicked = { selectedItem1 = null }, - onItemSelected = { + onItemSelected = { _, it -> selectedItem1 = it }, selectedItem = selectedItem1, @@ -121,27 +161,44 @@ class InputDropDownSnapshotTest { InputDropDown( title = "Label", state = InputShellState.ERROR, - dropdownItems = options, + itemCount = options.size, + fetchItem = { index -> + options[index] + }, + onSearchOption = { + }, + loadOptions = { + }, onResetButtonClicked = { selectedItem2 = null }, - onItemSelected = { + onItemSelected = { _, it -> selectedItem2 = it }, selectedItem = selectedItem2, ) Spacer(Modifier.size(Spacing.Spacing18)) - SubTitle("Disabled Input Dropdown with content ", textColor = TextColor.OnSurfaceVariant) + SubTitle( + "Disabled Input Dropdown with content ", + textColor = TextColor.OnSurfaceVariant, + ) var selectedItem3 by remember { mutableStateOf(options[1]) } InputDropDown( title = "Label", state = InputShellState.DISABLED, - dropdownItems = options, + itemCount = options.size, + fetchItem = { index -> + options[index] + }, + onSearchOption = { + }, + loadOptions = { + }, onResetButtonClicked = { selectedItem3 = null }, - onItemSelected = { + onItemSelected = { _, it -> selectedItem3 = it }, selectedItem = selectedItem3, diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt index 089621295..7eb074e87 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDown.kt @@ -4,15 +4,15 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Cancel -import androidx.compose.material3.DropdownMenu import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -34,6 +34,9 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -56,7 +59,9 @@ private const val MAX_DROPDOWN_ITEMS_TO_SHOW = 50 * @param title: controls the text to be shown for the title. * @param state: Manages the InputShell state. * @param inputStyle: Manages the InputShell style. - * @param dropdownItems: list of [DropdownItem] to be used. + * @param itemCount: controls the number of items to be shown. + * @param onSearchOption: callback to search for an specific option. + * @param fetchItem: gets the item to display in the list. * @param selectedItem: manages the value of the selected item. * @param supportingTextData: is a list of SupportingTextData that * manages all the messages to be shown. @@ -68,6 +73,8 @@ private const val MAX_DROPDOWN_ITEMS_TO_SHOW = 50 * @param onResetButtonClicked: callback to when reset button is clicked. * @param onItemSelected: callback to when a dropdown item is selected. * @param showSearchBar: config whether to show search bar in the bottom sheet. + * @param expanded: config whether the dropdown should be initially displayed. + * @param useDropDown: use dropdown if true. Bottomsheet with search capability otherwise. * @param noResultsFoundString: text to be shown in pop up when no results are found. */ @OptIn(ExperimentalMaterial3Api::class) @@ -76,7 +83,9 @@ fun InputDropDown( title: String, state: InputShellState, inputStyle: InputStyle = InputStyle.DataInputStyle(), - dropdownItems: List, + itemCount: Int, + onSearchOption: (String) -> Unit, + fetchItem: (index: Int) -> DropdownItem, selectedItem: DropdownItem? = null, supportingTextData: List? = null, legendData: LegendData? = null, @@ -84,14 +93,17 @@ fun InputDropDown( modifier: Modifier = Modifier, onFocusChanged: ((Boolean) -> Unit)? = null, onResetButtonClicked: () -> Unit, - onItemSelected: (DropdownItem) -> Unit, + onItemSelected: (index: Int, item: DropdownItem) -> Unit, showSearchBar: Boolean = true, expanded: Boolean = false, + useDropDown: Boolean = true, + loadOptions: () -> Unit, noResultsFoundString: String = provideStringResource("no_results_found"), searchToFindMoreString: String = provideStringResource("search_to_see_more"), ) { val focusRequester = remember { FocusRequester() } var showDropdown by remember { mutableStateOf(expanded) } + var currentItem by remember(selectedItem) { mutableStateOf(selectedItem) } val inputField: @Composable (modifier: Modifier) -> Unit = { inputModifier -> DropdownInputField( @@ -105,7 +117,7 @@ fun InputDropDown( onFocusChanged = onFocusChanged, supportingTextData = supportingTextData, legendData = legendData, - selectedItem = selectedItem, + selectedItem = currentItem, onResetButtonClicked = onResetButtonClicked, onDropdownIconClick = { showDropdown = !showDropdown @@ -113,61 +125,63 @@ fun InputDropDown( ) } - if (dropdownItems.size > MAX_DROPDOWN_ITEMS) { + if (!useDropDown) { Box { inputField(modifier) if (showDropdown) { var searchQuery by remember { mutableStateOf("") } - var filteredOptions = dropdownItems - - if (searchQuery.isNotEmpty()) { - filteredOptions = - dropdownItems.filter { it.label.contains(searchQuery, ignoreCase = true) } - } - + val scrollState = rememberLazyListState() BottomSheetShell( modifier = Modifier.testTag("INPUT_DROPDOWN_BOTTOM_SHEET"), title = title, + contentScrollState = scrollState, content = { - Column( + LazyColumn( modifier = Modifier .testTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS") + .semantics { + dropDownItemCount = itemCount + } .padding(top = Spacing8), + state = scrollState, ) { - if (filteredOptions.isNotEmpty()) { - filteredOptions - .take(MAX_DROPDOWN_ITEMS_TO_SHOW) - .forEachIndexed { index, item -> - DropdownItem( - modifier = Modifier.testTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEM_$index"), - item = item, - selected = selectedItem == item, - contentPadding = PaddingValues(Spacing8), - onItemClick = { - onItemSelected(item) - showDropdown = false - }, - ) + when { + itemCount > 0 -> + items(count = itemCount) { index -> + with(fetchItem(index)) { + DropdownItem( + modifier = Modifier.testTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEM_$index"), + item = this, + selected = selectedItem == this, + contentPadding = PaddingValues(Spacing8), + onItemClick = { + currentItem = this + onItemSelected(index, this) + showDropdown = false + }, + ) + } + } + + searchQuery.isEmpty() -> { + item { + ProgressIndicator(type = ProgressIndicatorType.CIRCULAR_SMALL) } - if (filteredOptions.size > MAX_DROPDOWN_ITEMS_TO_SHOW) { - Text( - text = searchToFindMoreString, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - ) + loadOptions() } - } else { - Text( - text = noResultsFoundString, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - ) + + else -> + item { + Text( + text = noResultsFoundString, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + ) + } } } }, @@ -179,8 +193,14 @@ fun InputDropDown( } else { null }, - onSearch = { searchQuery = it }, - onSearchQueryChanged = { searchQuery = it }, + onSearch = { + searchQuery = it + onSearchOption(it) + }, + onSearchQueryChanged = { + searchQuery = it + onSearchOption(it) + }, ) } } @@ -199,10 +219,7 @@ fun InputDropDown( MaterialTheme( shapes = Shapes(extraSmall = RoundedCornerShape(Spacing8)), ) { - // TODO: Replace with ExposedDropdownMenu once the fix for the following issue - // is available in Compose Multiplatform - // https://issuetracker.google.com/issues/205589613 - DropdownMenu( + ExposedDropdownMenu( expanded = showDropdown, onDismissRequest = { showDropdown = false }, modifier = Modifier.background( @@ -210,22 +227,25 @@ fun InputDropDown( shape = RoundedCornerShape(Spacing8), ).exposedDropdownSize().testTag("INPUT_DROPDOWN_MENU"), ) { - dropdownItems.forEachIndexed { index, item -> - DropdownItem( - modifier = Modifier.testTag("INPUT_DROPDOWN_MENU_ITEM_$index") - .fillMaxWidth() - .padding(start = dropdownStartPadding(inputStyle) + 8.dp), - item = item, - selected = selectedItem == item, - contentPadding = PaddingValues( - horizontal = Spacing8, - vertical = Spacing16, - ), - onItemClick = { - onItemSelected(item) - showDropdown = false - }, - ) + repeat(itemCount) { index -> + with(fetchItem(index)) { + DropdownItem( + modifier = Modifier.testTag("INPUT_DROPDOWN_MENU_ITEM_$index") + .fillMaxWidth() + .padding(start = dropdownStartPadding(inputStyle) + 8.dp), + item = this, + selected = selectedItem == this, + contentPadding = PaddingValues( + horizontal = Spacing8, + vertical = Spacing16, + ), + onItemClick = { + currentItem = this + onItemSelected(index, this) + showDropdown = false + }, + ) + } } } } @@ -417,3 +437,8 @@ private fun DropdownItem( @Immutable data class DropdownItem(val label: String) + +val DropDownItemCount = SemanticsPropertyKey( + name = "DropDownItemCount", +) +var SemanticsPropertyReceiver.dropDownItemCount by DropDownItemCount diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt index 853a4e499..4c80538ba 100644 --- a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputDropDownTest.kt @@ -1,19 +1,27 @@ +@file:OptIn(ExperimentalTestApi::class) + package org.hisp.dhis.mobile.ui.designsystem.component import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onChildren import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor import org.junit.Rule @@ -25,13 +33,21 @@ class InputDropDownTest { @Test fun shouldAllowDropDownSelectionWhenEnabled() { + val options = listOf(DropdownItem("Option 1")) rule.setContent { InputDropDown( title = "Label", - dropdownItems = listOf(DropdownItem("Option 1")), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -40,13 +56,21 @@ class InputDropDownTest { @Test fun shouldNotAllowDropDownSelectionWhenDisabled() { + val options = listOf(DropdownItem("Option 1")) rule.setContent { InputDropDown( title = "Label", - dropdownItems = listOf(DropdownItem("Option 1")), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, state = InputShellState.DISABLED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -55,16 +79,23 @@ class InputDropDownTest { @Test fun shouldShowResetButtonWhenItemIsSelected() { - val dropdownItem = DropdownItem("Option 1") + val options = listOf(DropdownItem("Option 1")) rule.setContent { InputDropDown( title = "Label", - dropdownItems = listOf(dropdownItem), - selectedItem = dropdownItem, + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, + selectedItem = options[0], state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -73,13 +104,21 @@ class InputDropDownTest { @Test fun shouldHideResetButtonWhenNoItemIsSelected() { + val options = emptyList() rule.setContent { InputDropDown( title = "Label", - dropdownItems = emptyList(), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -89,15 +128,22 @@ class InputDropDownTest { @Test fun shouldHideResetButtonWhenDisabled() { rule.setContent { - val dropdownItem = DropdownItem("Option 1") + val options = listOf(DropdownItem("Option 1")) InputDropDown( title = "Label", - dropdownItems = listOf(dropdownItem), - selectedItem = dropdownItem, + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, + selectedItem = options[0], state = InputShellState.DISABLED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -107,19 +153,26 @@ class InputDropDownTest { @Test fun shouldRemoveSelectedItemWhenResetButtonIsClickedAndHideResetButton() { rule.setContent { - val dropdownItem = DropdownItem("Option 1") - var itemSelected by rememberSaveable { mutableStateOf(dropdownItem) } + val options = listOf(DropdownItem("Option 1")) + var itemSelected by rememberSaveable { mutableStateOf(options.first()) } InputDropDown( title = "Label", - dropdownItems = listOf(dropdownItem), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, selectedItem = itemSelected, state = InputShellState.UNFOCUSED, onResetButtonClicked = { itemSelected = null }, - onItemSelected = { - itemSelected = it + onItemSelected = { _, item -> + itemSelected = item }, ) } @@ -132,14 +185,22 @@ class InputDropDownTest { @Test fun shouldShowLegendCorrectly() { + val options = emptyList() rule.setContent { InputDropDown( title = "Label", - dropdownItems = emptyList(), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -148,14 +209,27 @@ class InputDropDownTest { @Test fun shouldShowSupportingTextCorrectly() { + val options = emptyList() rule.setContent { InputDropDown( title = "Label", - dropdownItems = emptyList(), - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -164,7 +238,7 @@ class InputDropDownTest { @Test fun shouldShowDropdownMenuOnIconClickIfThereAreLessThan7Items() { - val dropdownItems = listOf( + val options = listOf( DropdownItem("Option 1"), DropdownItem("Option 2"), DropdownItem("Option 3"), @@ -176,11 +250,23 @@ class InputDropDownTest { rule.setContent { InputDropDown( title = "Label", - dropdownItems = dropdownItems, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -190,7 +276,7 @@ class InputDropDownTest { @Test fun shouldShowBottomSheetOnIconClickIfThereAre7OrMoreItems() { - val dropdownItems = listOf( + val options = listOf( DropdownItem("Option 1"), DropdownItem("Option 2"), DropdownItem("Option 3"), @@ -203,11 +289,24 @@ class InputDropDownTest { rule.setContent { InputDropDown( title = "Label", - dropdownItems = dropdownItems, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, + useDropDown = false, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -217,7 +316,7 @@ class InputDropDownTest { @Test fun shouldOnlyShowMatchedSearchResultsInBottomSheet() { - val dropdownItems = listOf( + val options = listOf( DropdownItem("Option 1"), DropdownItem("Option 2"), DropdownItem("Option 3"), @@ -233,31 +332,56 @@ class InputDropDownTest { val searchSemantics = "Search" rule.setContent { + var filteredOptions by remember { mutableStateOf(options) } InputDropDown( title = "Label", - dropdownItems = dropdownItems, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + itemCount = filteredOptions.size, + onSearchOption = { query -> + filteredOptions = options.filter { it.label.contains(query) }.toMutableList() + }, + fetchItem = { + filteredOptions[it] + }, + loadOptions = { + }, + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, + useDropDown = false, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON").performClick() rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET").assertExists() - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(10) + rule.onNode( + hasTestTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").and( + SemanticsMatcher.expectValue(DropDownItemCount, options.size), + ), + ).assertExists() rule.onNodeWithContentDescription(searchSemantics).assertExists() // Search rule.onNodeWithContentDescription(searchSemantics).performTextInput("Option 1") - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(2) - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren()[0].assertTextEquals("Option 1") - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren()[1].assertTextEquals("Option 10") + rule.onNode( + hasTestTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").and( + SemanticsMatcher.expectValue(DropDownItemCount, 2), + ), + ).assertExists() + rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS") + .onChildren()[0].assertTextEquals("Option 1") + rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS") + .onChildren()[1].assertTextEquals("Option 10") } @Test fun shouldNoResultsFoundTextWhenThereAreNoSearchResults() { - val dropdownItems = listOf( + val options = listOf( DropdownItem("Option 1"), DropdownItem("Option 2"), DropdownItem("Option 3"), @@ -273,13 +397,29 @@ class InputDropDownTest { val searchSemantics = "Search" rule.setContent { + var filteredOptions by remember { mutableStateOf(options) } InputDropDown( title = "Label", - dropdownItems = dropdownItems, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + itemCount = filteredOptions.size, + onSearchOption = { query -> + filteredOptions = options.filter { it.label.contains(query) } + }, + fetchItem = { + filteredOptions[it] + }, + loadOptions = { + }, + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, + useDropDown = false, + noResultsFoundString = "No results found", ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -290,13 +430,17 @@ class InputDropDownTest { // Search rule.onNodeWithContentDescription(searchSemantics).performTextInput("Option 50") - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(1) + rule.onNode( + hasTestTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").and( + SemanticsMatcher.expectValue(DropDownItemCount, 0), + ), + ).assertExists() rule.onNodeWithText("No results found").assertExists() } @Test fun shouldNotShowSearchBarWhenSearchBarConfigIsFalse() { - val dropdownItems = listOf( + val options = listOf( DropdownItem("Option 1"), DropdownItem("Option 2"), DropdownItem("Option 3"), @@ -314,12 +458,25 @@ class InputDropDownTest { rule.setContent { InputDropDown( title = "Label", - dropdownItems = dropdownItems, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, showSearchBar = false, + useDropDown = false, ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -331,7 +488,7 @@ class InputDropDownTest { @Test fun clickingOnDropdownMenuItemShouldTriggerCallbackAndDismissMenu() { - val dropdownItems = listOf( + val options = listOf( DropdownItem("Option 1"), DropdownItem("Option 2"), DropdownItem("Option 3"), @@ -345,13 +502,25 @@ class InputDropDownTest { rule.setContent { InputDropDown( title = "Label", - dropdownItems = dropdownItems, + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, selectedItem = selectedItem, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = { - selectedItem = it + onItemSelected = { _, item -> + selectedItem = item }, ) } @@ -360,12 +529,12 @@ class InputDropDownTest { rule.onNodeWithTag("INPUT_DROPDOWN_MENU").assertExists() rule.onNodeWithTag("INPUT_DROPDOWN_MENU_ITEM_0").performClick() rule.onNodeWithTag("INPUT_DROPDOWN_MENU").assertDoesNotExist() - assert(selectedItem == dropdownItems.first()) + assert(selectedItem == options.first()) } @Test fun clickingOnBottomSheetItemShouldTriggerCallbackAndDismissBottomSheet() { - val dropdownItems = listOf( + val options = listOf( DropdownItem("Option 1"), DropdownItem("Option 2"), DropdownItem("Option 3"), @@ -380,14 +549,28 @@ class InputDropDownTest { rule.setContent { InputDropDown( title = "Label", - dropdownItems = dropdownItems, + itemCount = options.size, + onSearchOption = { + }, + fetchItem = { + options[it] + }, + loadOptions = { + }, selectedItem = selectedItem, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = { - selectedItem = it + onItemSelected = { _, item -> + selectedItem = item }, + useDropDown = false, + noResultsFoundString = "No results found", ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() @@ -395,14 +578,14 @@ class InputDropDownTest { rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET").assertExists() rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEM_2").performClick() rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET").assertDoesNotExist() - assert(selectedItem == dropdownItems[2]) + assert(selectedItem == options[2]) } @Test fun shouldShowSearchForMoreOptionTextWhenMoreThan50Option() { - val dropdownItems = mutableListOf() + val options = mutableListOf() for (i in 1..100) { - dropdownItems.add( + options.add( DropdownItem("Option $i"), ) } @@ -410,35 +593,73 @@ class InputDropDownTest { val searchSemantics = "Search" rule.setContent { + var filteredOptions by remember { mutableStateOf(options) } + InputDropDown( title = "Label", - dropdownItems = dropdownItems, - supportingTextData = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + itemCount = filteredOptions.size, + onSearchOption = { query -> + filteredOptions = options.filter { it.label.contains(query) }.toMutableList() + }, + fetchItem = { + filteredOptions[it] + }, + loadOptions = { + }, + supportingTextData = listOf( + SupportingTextData( + "Supporting text", + SupportingTextState.DEFAULT, + ), + ), state = InputShellState.UNFOCUSED, onResetButtonClicked = {}, - onItemSelected = {}, + onItemSelected = { _, _ -> }, + useDropDown = false, + noResultsFoundString = "No results found", ) } rule.onNodeWithTag("INPUT_DROPDOWN").assertExists() rule.onNodeWithTag("INPUT_DROPDOWN_ARROW_BUTTON").performClick() rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET").assertExists() - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(51) + rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(10) rule.onNodeWithContentDescription(searchSemantics).assertExists() - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(51) - rule.onNodeWithText("Not all options are displayed.\\n Search to see more.").assertExists() + rule.onNode( + hasTestTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").and( + SemanticsMatcher.expectValue(DropDownItemCount, options.size), + ), + ).assertExists() + rule.onNodeWithText("Not all options are displayed.\\n Search to see more.") + .assertDoesNotExist() // Search rule.onNodeWithContentDescription(searchSemantics).performTextInput("5") - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(19) - rule.onNodeWithText("Not all options are displayed.\\n Search to see more.").assertDoesNotExist() - + rule.onNode( + hasTestTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").and( + SemanticsMatcher.expectValue(DropDownItemCount, 19), + ), + ).assertExists() + rule.onNodeWithText("Not all options are displayed.\\n Search to see more.") + .assertDoesNotExist() + + rule.onNodeWithContentDescription(searchSemantics).performTextClearance() rule.onNodeWithContentDescription(searchSemantics).performTextInput("55") - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(1) - rule.onNodeWithText("Not all options are displayed.\\n Search to see more.").assertDoesNotExist() - + rule.onNode( + hasTestTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").and( + SemanticsMatcher.expectValue(DropDownItemCount, options.filter { it.label.contains("55") }.size), + ), + ).assertExists() + rule.onNodeWithText("Not all options are displayed.\\n Search to see more.") + .assertDoesNotExist() + + rule.onNodeWithContentDescription(searchSemantics).performTextClearance() rule.onNodeWithContentDescription(searchSemantics).performTextInput("555") - rule.onNodeWithTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").onChildren().assertCountEquals(1) + rule.onNode( + hasTestTag("INPUT_DROPDOWN_BOTTOM_SHEET_ITEMS").and( + SemanticsMatcher.expectValue(DropDownItemCount, 0), + ), + ).assertExists() rule.onNodeWithText("No results found").assertExists() } }