From ce0ac084009a7e08275c10a45ad851892c9467e3 Mon Sep 17 00:00:00 2001 From: Sasikanth Miriyampalli Date: Wed, 24 Jan 2024 15:09:23 +0530 Subject: [PATCH] ANDROAPP-5672-mobile-ui-org-unit-tree-component (#170) * Add custom DHIS2 icons for org tree items * Add `OrgBottomSheet` * Add org tree bottom sheet screen * Add no results found view to org tree bottom sheet * Disable clear all button if no items are selected in org tree * Mark title as nullable in `OrgBottomSheet` * Lock org tree dialog height based on org tree list to avoid jumping when list is updated * Highlight search query in matching org tree items * Rename `dhis2_org_tree_item_circle` to `material_circle_outline` * Replace `material_circle_outline` icon with 24dp frame icon * Use material icons for arrow icons in org tree item * Remove unused icons * Fix org bottom sheet sample crash * Add tests for org bottom sheet --- .../kotlin/org/hisp/dhis/common/App.kt | 4 +- .../hisp/dhis/common/screens/Components.kt | 1 + .../screens/OrgTreeBottomSheetScreen.kt | 201 +++++++++++ .../designsystem/component/OrgBottomSheet.kt | 331 ++++++++++++++++++ .../drawable/material_circle_outline.xml | 10 + .../resources/values/strings_en.xml | 2 + .../component/OrgBottomSheetTest.kt | 126 +++++++ 7 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/OrgTreeBottomSheetScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt create mode 100644 designsystem/src/commonMain/resources/drawable/material_circle_outline.xml create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt 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 81fd793d3..1ec4017ac 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -73,6 +73,7 @@ import org.hisp.dhis.common.screens.LegendDescriptionScreen import org.hisp.dhis.common.screens.LegendScreen import org.hisp.dhis.common.screens.ListCardScreen import org.hisp.dhis.common.screens.MetadataAvatarScreen +import org.hisp.dhis.common.screens.OrgTreeBottomSheetScreen import org.hisp.dhis.common.screens.ProgressScreen import org.hisp.dhis.common.screens.RadioButtonScreen import org.hisp.dhis.common.screens.SearchBarScreen @@ -92,7 +93,7 @@ fun App() { @Composable fun Main() { - val currentScreen = remember { mutableStateOf(Components.BUTTON) } + val currentScreen = remember { mutableStateOf(Components.ORG_TREE_BOTTOM_SHEET) } var expanded by remember { mutableStateOf(false) } Column( @@ -197,6 +198,7 @@ fun Main() { Components.SEARCH_BAR -> SearchBarScreen() Components.INPUT_NOT_SUPPORTED -> InputNotSupportedScreen() Components.FULL_SCREEN_IMAGE -> FullScreenImageScreen() + Components.ORG_TREE_BOTTOM_SHEET -> OrgTreeBottomSheetScreen() } } } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt index 58d371f78..2292b8cad 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/Components.kt @@ -59,4 +59,5 @@ enum class Components(val label: String) { SEARCH_BAR("Search bar"), INPUT_NOT_SUPPORTED("Input Not Supported"), FULL_SCREEN_IMAGE("Full Screen Image"), + ORG_TREE_BOTTOM_SHEET("Org Tree Bottom Sheet"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/OrgTreeBottomSheetScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/OrgTreeBottomSheetScreen.kt new file mode 100644 index 000000000..5ae912a13 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/OrgTreeBottomSheetScreen.kt @@ -0,0 +1,201 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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 kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.hisp.dhis.mobile.ui.designsystem.component.Button +import org.hisp.dhis.mobile.ui.designsystem.component.ButtonStyle +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.OrgBottomSheet +import org.hisp.dhis.mobile.ui.designsystem.component.OrgTreeItem +import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle + +@Composable +fun OrgTreeBottomSheetScreen() { + var showOrgTreeBottomSheet by rememberSaveable { mutableStateOf(false) } + + if (showOrgTreeBottomSheet) { + val orgTreeItemsRepo = remember { OrgTreeItemsFakeRepo() } + val orgTreeItems by orgTreeItemsRepo.state.collectAsState(emptyList()) + + OrgBottomSheet( + orgTreeItems = orgTreeItems, + onDismiss = { + showOrgTreeBottomSheet = false + }, + onSearch = orgTreeItemsRepo::search, + onItemClick = orgTreeItemsRepo::toggleItemExpansion, + onItemSelected = { uid, checked -> + orgTreeItemsRepo.toggleItemSelection(uid, checked) + }, + onClearAll = { orgTreeItemsRepo.clearItemSelections() }, + onDone = { + // no-op + }, + ) + } + + ColumnComponentContainer { + SubTitle("Org Tree Bottom Sheet") + Button( + enabled = true, + ButtonStyle.FILLED, + text = "Show Org Tree Bottom Sheet", + ) { + showOrgTreeBottomSheet = !showOrgTreeBottomSheet + } + } +} + +private class OrgTreeItemsFakeRepo { + + private val originalOrgTreeItems = listOf( + OrgTreeItem( + uid = "12", + label = "Krishna", + isOpen = true, + hasChildren = true, + ), + OrgTreeItem( + uid = "21", + label = "Guntur", + isOpen = false, + hasChildren = false, + ), + ) + + private val childrenOrgItems = listOf( + OrgTreeItem( + uid = "12-1", + label = "Vijayawada", + isOpen = false, + level = 1, + hasChildren = false, + ), + OrgTreeItem( + uid = "12-2", + label = "Gudivada", + isOpen = false, + level = 1, + hasChildren = false, + ), + ) + + private val coroutineScope = CoroutineScope(Dispatchers.Default) + + private val _state = MutableStateFlow(createList(originalOrgTreeItems, childrenOrgItems)) + val state: StateFlow> = + _state.stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = createList(originalOrgTreeItems, childrenOrgItems), + ) + + fun search(query: String) { + coroutineScope.launch { + if (query.isNotBlank()) { + val filteredList = originalOrgTreeItems.filter { it.label.contains(query, ignoreCase = true) } + _state.emit(filteredList) + } else { + _state.emit(originalOrgTreeItems) + } + } + } + + fun toggleItemExpansion(uid: String) { + coroutineScope.launch { + val updatedList = _state.value + .map { + if (it.hasChildren && it.uid == uid) { + it.copy(isOpen = !it.isOpen) + } else { + it + } + } + val parentItem = updatedList.first { it.uid == uid } + + val newList = if (parentItem.isOpen) { + createList(updatedList, childrenOrgItems) + } else { + updatedList.filterNot { it.uid.contains(uid) && it.level > 0 } + } + + _state.emit(newList) + } + } + + fun toggleItemSelection(uid: String, selected: Boolean) { + coroutineScope.launch { + val selectionToggledList = _state.value.map { + if (it.uid.contains(uid, ignoreCase = true)) { + val selectedChildrenCount = + _state.value.count { it.uid.contains("12") && it.level > 0 && it.selected } + + it.copy(selected = selected, selectedChildrenCount = selectedChildrenCount) + } else { + it + } + } + + val newList = selectionToggledList.map { + if (!uid.contains("12")) { + return@map it + } + + when (it.uid) { + "12" -> { + val selectedChildrenCount = getSelectedChildrenCount(selectionToggledList, it) + it.copy(selectedChildrenCount = selectedChildrenCount) + } + else -> { + it.copy(selectedChildrenCount = 0) + } + } + } + + _state.emit(newList) + } + } + + private fun getSelectedChildrenCount( + selectionToggledList: List, + it: OrgTreeItem, + ): Int { + val hasChildrenItems = selectionToggledList.any { it.uid == "12-1" || it.uid == "12-2" } + + return if (hasChildrenItems) { + selectionToggledList.count { it.uid.contains("12") && it.level > 0 && it.selected } + } else { + if (it.selected) 2 else 0 + } + } + + fun clearItemSelections() { + coroutineScope.launch { + _state.emit(_state.value.map { it.copy(selected = false) }) + } + } + + private fun createList(parentItems: List, childrenItems: List): List { + val updatedChildrenItems = childrenItems.map { + if (parentItems.first { it.uid == "12" }.selected) { + it.copy(selected = true) + } else { + it + } + } + + return (parentItems + updatedChildrenItems).sortedBy { it.uid } + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt new file mode 100644 index 000000000..52d052241 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheet.kt @@ -0,0 +1,331 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeightIn +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ClearAll +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import org.hisp.dhis.mobile.ui.designsystem.resource.provideDHIS2Icon +import org.hisp.dhis.mobile.ui.designsystem.resource.provideStringResource +import org.hisp.dhis.mobile.ui.designsystem.theme.DHIS2SCustomTextStyles +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.hisp.dhis.mobile.ui.designsystem.theme.TextColor + +@Composable +fun OrgBottomSheet( + orgTreeItems: List, + modifier: Modifier = Modifier, + title: String? = null, + subtitle: String? = null, + description: String? = null, + clearAllButtonText: String = provideStringResource("clear_all"), + doneButtonText: String = provideStringResource("done"), + noResultsFoundText: String = provideStringResource("org_tree_no_results_found"), + icon: @Composable (() -> Unit)? = null, + onSearch: ((String) -> Unit)? = null, + onDismiss: () -> Unit, + onItemClick: (uid: String) -> Unit, + onItemSelected: (uid: String, checked: Boolean) -> Unit, + onClearAll: () -> Unit, + onDone: () -> Unit, +) { + val listState = rememberLazyListState() + var searchQuery by remember { mutableStateOf("") } + var orgTreeHeight by remember { mutableStateOf(0) } + val orgTreeHeightInDp = with(LocalDensity.current) { orgTreeHeight.toDp() } + + BottomSheetShell( + modifier = modifier, + title = title, + subtitle = subtitle, + description = description, + icon = icon, + searchQuery = searchQuery, + onSearchQueryChanged = { query -> + searchQuery = query + onSearch?.invoke(searchQuery) + }, + onSearch = onSearch, + contentScrollState = listState, + content = { + OrgTreeList( + state = listState, + orgTreeItems = orgTreeItems, + searchQuery = searchQuery, + noResultsFoundText = noResultsFoundText, + onItemClick = onItemClick, + onItemSelected = onItemSelected, + modifier = Modifier + .onGloballyPositioned { coordinates -> + val treeHeight = coordinates.size.height + if (treeHeight > orgTreeHeight) { + orgTreeHeight = treeHeight + } + } + .requiredHeightIn(min = orgTreeHeightInDp), + ) + }, + buttonBlock = { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + modifier = Modifier.weight(1f) + .testTag("CLEAR_ALL_BUTTON"), + onClick = onClearAll, + icon = { + Icon( + imageVector = Icons.Filled.ClearAll, + contentDescription = null, + ) + }, + text = clearAllButtonText, + enabled = orgTreeItems.any { it.selected }, + ) + + Spacer(Modifier.requiredWidth(Spacing.Spacing16)) + + Button( + modifier = Modifier.weight(1f), + onClick = onDone, + icon = { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + ) + }, + text = doneButtonText, + style = ButtonStyle.FILLED, + ) + } + }, + onDismiss = onDismiss, + ) +} + +@Composable +private fun OrgTreeList( + state: LazyListState, + orgTreeItems: List, + searchQuery: String, + noResultsFoundText: String, + modifier: Modifier = Modifier, + onItemClick: (orgUnitUid: String) -> Unit, + onItemSelected: (orgUnitUid: String, checked: Boolean) -> Unit, +) { + val hasSearchQuery by derivedStateOf { searchQuery.isNotBlank() } + if (orgTreeItems.isEmpty() && hasSearchQuery) { + Text( + modifier = modifier + .fillMaxWidth() + .padding(top = Spacing.Spacing24, bottom = Spacing.Spacing96) + .padding(horizontal = Spacing.Spacing16) + .testTag("ORG_TREE_NO_RESULTS_FOUND"), + textAlign = TextAlign.Center, + text = noResultsFoundText, + color = TextColor.OnSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } else { + LazyColumn( + modifier = modifier + .testTag("ORG_TREE_LIST"), + state = state, + horizontalAlignment = Alignment.Start, + ) { + items(orgTreeItems) { item -> + OrgUnitSelectorItem( + orgTreeItem = item, + searchQuery = searchQuery, + onItemClick = onItemClick, + onItemSelected = onItemSelected, + ) + } + } + } +} + +@Composable +fun OrgUnitSelectorItem( + orgTreeItem: OrgTreeItem, + searchQuery: String, + modifier: Modifier = Modifier, + onItemClick: (uid: String) -> Unit, + onItemSelected: (uid: String, checked: Boolean) -> Unit, +) { + Row( + modifier = modifier + .testTag("$ITEM_TEST_TAG${orgTreeItem.label}") + .fillMaxWidth() + .background(Color.White) + .clickable( + enabled = orgTreeItem.hasChildren, + interactionSource = remember { + MutableInteractionSource() + }, + indication = rememberRipple(bounded = true), + ) { + onItemClick(orgTreeItem.uid) + } + .padding(start = ((orgTreeItem.level * 2) * 16).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = orgTreeItemIcon(orgTreeItem) + val iconTint = if (orgTreeItem.isOpen && orgTreeItem.hasChildren) { + TextColor.OnDisabledSurface + } else if (!orgTreeItem.isOpen && !orgTreeItem.hasChildren) { + TextColor.OnDisabledSurface + } else { + TextColor.OnSurfaceVariant + } + + Icon( + modifier = Modifier.padding(Spacing.Spacing8), + painter = icon, + tint = iconTint, + contentDescription = "", + ) + + Text( + modifier = Modifier.weight(1f), + text = orgTreeItemLabel( + orgTreeItem = orgTreeItem, + searchQuery = searchQuery, + ), + style = DHIS2SCustomTextStyles.bodyLargeBold.copy( + fontWeight = if (orgTreeItem.selectedChildrenCount > 0 || orgTreeItem.selected) { + FontWeight.Bold + } else { + FontWeight.Normal + }, + ), + ) + + if (orgTreeItem.canBeSelected) { + Checkbox( + modifier = Modifier.testTag("$ITEM_CHECK_TEST_TAG${orgTreeItem.label}"), + checked = orgTreeItem.selected, + onCheckedChange = { isChecked -> + onItemSelected(orgTreeItem.uid, isChecked) + }, + ) + } + } +} + +@Composable +private fun orgTreeItemIcon(orgTreeItem: OrgTreeItem): Painter { + if (!orgTreeItem.hasChildren) return provideDHIS2Icon("material_circle_outline") + + return if (orgTreeItem.isOpen) { + rememberVectorPainter(Icons.Filled.KeyboardArrowDown) + } else { + rememberVectorPainter(Icons.Filled.KeyboardArrowRight) + } +} + +private fun orgTreeItemLabel( + orgTreeItem: OrgTreeItem, + searchQuery: String, +): AnnotatedString { + val label = buildAnnotatedString { + val highlightIndexStart = orgTreeItem.label.indexOf(searchQuery, ignoreCase = true) + val highlightIndexEnd = highlightIndexStart + searchQuery.length + + if (highlightIndexStart >= 0) { + appendHighlightedString( + orgTreeItem = orgTreeItem, + highlightIndexStart = highlightIndexStart, + highlightIndexEnd = highlightIndexEnd, + ) + } else { + append(orgTreeItem.label) + } + + if (orgTreeItem.selectedChildrenCount > 0 && orgTreeItem.hasChildren) { + append(" (${orgTreeItem.selectedChildrenCount})") + } + } + + return label +} + +private fun AnnotatedString.Builder.appendHighlightedString( + orgTreeItem: OrgTreeItem, + highlightIndexStart: Int, + highlightIndexEnd: Int, +) { + append(orgTreeItem.label.substring(0, highlightIndexStart)) + + withStyle( + SpanStyle(background = SurfaceColor.Primary.copy(alpha = 0.1f)), + ) { + append( + orgTreeItem.label.substring( + startIndex = highlightIndexStart, + endIndex = highlightIndexEnd, + ), + ) + } + + append( + orgTreeItem.label.substring( + startIndex = highlightIndexEnd, + endIndex = orgTreeItem.label.length, + ), + ) +} + +data class OrgTreeItem( + val uid: String, + val label: String, + var isOpen: Boolean = true, + val hasChildren: Boolean = false, + val selected: Boolean = false, + val level: Int = 0, + val selectedChildrenCount: Int = 0, + val canBeSelected: Boolean = true, +) + +const val ITEM_TEST_TAG = "ORG_TREE_ITEM_" +const val ITEM_CHECK_TEST_TAG = "ORG_TREE_ITEM_CHECKBOX_" diff --git a/designsystem/src/commonMain/resources/drawable/material_circle_outline.xml b/designsystem/src/commonMain/resources/drawable/material_circle_outline.xml new file mode 100644 index 000000000..97304ecb0 --- /dev/null +++ b/designsystem/src/commonMain/resources/drawable/material_circle_outline.xml @@ -0,0 +1,10 @@ + + + diff --git a/designsystem/src/commonMain/resources/values/strings_en.xml b/designsystem/src/commonMain/resources/values/strings_en.xml index c3b866c69..764bc5e94 100644 --- a/designsystem/src/commonMain/resources/values/strings_en.xml +++ b/designsystem/src/commonMain/resources/values/strings_en.xml @@ -33,4 +33,6 @@ Draw here Reset Done + Clear all + No results found diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt new file mode 100644 index 000000000..cd382796d --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/OrgBottomSheetTest.kt @@ -0,0 +1,126 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.junit.Rule +import org.junit.Test + +class OrgBottomSheetTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun clearAllButtonShouldBeDisabledWhenThereAreNoSelectedItems() { + rule.setContent { + OrgBottomSheet( + orgTreeItems = listOf( + OrgTreeItem( + uid = "1", + label = "Item 1", + selected = false, + ), + OrgTreeItem( + uid = "2", + label = "Item 2", + selected = false, + ), + ), + onDismiss = { + // no-op + }, + onItemClick = { + // no-op + }, + onItemSelected = { _, _ -> + // no-op + }, + onClearAll = { + // no-op + }, + onDone = { + // no-op + }, + ) + } + + rule.onNodeWithTag("CLEAR_ALL_BUTTON").assertIsNotEnabled() + } + + @Test + fun clearAllButtonShouldBeEnabledWhenThereIsOneSelectedItems() { + rule.setContent { + OrgBottomSheet( + orgTreeItems = listOf( + OrgTreeItem( + uid = "1", + label = "Item 1", + selected = true, + ), + OrgTreeItem( + uid = "2", + label = "Item 2", + selected = false, + ), + ), + onDismiss = { + // no-op + }, + onItemClick = { + // no-op + }, + onItemSelected = { _, _ -> + // no-op + }, + onClearAll = { + // no-op + }, + onDone = { + // no-op + }, + ) + } + + rule.onNodeWithTag("CLEAR_ALL_BUTTON").assertIsEnabled() + } + + @Test + fun showCheckBoxIfItemCanBeSelected() { + rule.setContent { + OrgBottomSheet( + orgTreeItems = listOf( + OrgTreeItem( + uid = "1", + label = "Item 1", + canBeSelected = true, + ), + OrgTreeItem( + uid = "2", + label = "Item 2", + canBeSelected = false, + ), + ), + onDismiss = { + // no-op + }, + onItemClick = { + // no-op + }, + onItemSelected = { _, _ -> + // no-op + }, + onClearAll = { + // no-op + }, + onDone = { + // no-op + }, + ) + } + + rule.onNodeWithTag("ORG_TREE_ITEM_CHECKBOX_Item 1").assertExists() + rule.onNodeWithTag("ORG_TREE_ITEM_CHECKBOX_Item 2").assertDoesNotExist() + } +}