From cec38701c47e8c1563689a2496274b36ae48bbce Mon Sep 17 00:00:00 2001 From: Siddharth Agarwal Date: Mon, 18 Sep 2023 11:15:26 +0530 Subject: [PATCH] ANDROAPP-5544-mobile-ui-Create-Input-Type-Radio-Button (#57) ANDROAPP-5544-mobile-ui-Create-Input-Type-Radio-Button --- .../kotlin/org/hisp/dhis/common/App.kt | 2 + .../hisp/dhis/common/screens/Components.kt | 1 + .../common/screens/InputRadioButtonScreen.kt | 111 ++++++++ .../component/InputRadioButton.kt | 93 +++++++ .../ui/designsystem/component/RadioButton.kt | 20 +- .../component/InputRadioButtonTest.kt | 245 ++++++++++++++++++ 6 files changed, 459 insertions(+), 13 deletions(-) create mode 100644 common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputRadioButtonScreen.kt create mode 100644 designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt create mode 100644 designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButtonTest.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 9ac8b9f73..e48637529 100644 --- a/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/App.kt @@ -38,6 +38,7 @@ import org.hisp.dhis.common.screens.InputNumberScreen import org.hisp.dhis.common.screens.InputPercentageScreen import org.hisp.dhis.common.screens.InputPositiveIntegerOrZeroScreen import org.hisp.dhis.common.screens.InputPositiveIntegerScreen +import org.hisp.dhis.common.screens.InputRadioButtonScreen import org.hisp.dhis.common.screens.InputScreen import org.hisp.dhis.common.screens.InputTextScreen import org.hisp.dhis.common.screens.LegendDescriptionScreen @@ -131,6 +132,7 @@ fun Main() { Components.INPUT_INTEGER -> InputIntegerScreen() Components.INPUT_NUMBER -> InputNumberScreen() Components.INPUT_LETTER -> InputLetterScreen() + Components.INPUT_RADIO_BUTTON -> InputRadioButtonScreen() } } } 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 83ce3ac26..2a0f74e2e 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 @@ -26,4 +26,5 @@ enum class Components(val label: String) { INPUT("Input"), BUTTON_BLOCK("Button block"), ICON_CARDS("Icon Cards"), + INPUT_RADIO_BUTTON("Input Radio Button"), } diff --git a/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputRadioButtonScreen.kt b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputRadioButtonScreen.kt new file mode 100644 index 000000000..03722fac3 --- /dev/null +++ b/common/src/commonMain/kotlin/org/hisp/dhis/common/screens/InputRadioButtonScreen.kt @@ -0,0 +1,111 @@ +package org.hisp.dhis.common.screens + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import org.hisp.dhis.mobile.ui.designsystem.component.ColumnComponentContainer +import org.hisp.dhis.mobile.ui.designsystem.component.InputRadioButton +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState +import org.hisp.dhis.mobile.ui.designsystem.component.Orientation +import org.hisp.dhis.mobile.ui.designsystem.component.RadioButtonData +import org.hisp.dhis.mobile.ui.designsystem.component.SubTitle +import org.hisp.dhis.mobile.ui.designsystem.theme.Spacing + +@Composable +fun InputRadioButtonScreen() { + val option1 = "Option 1" + val option2 = "Option 2" + val option3 = "Option 3" + val option4 = "Option 4" + val option5 = "Option 5" + val option6 = "Option 6" + + val radioButtonDataItemsVertical = listOf( + RadioButtonData("0", selected = true, enabled = true, textInput = option1), + RadioButtonData("1", selected = false, enabled = true, textInput = option2), + RadioButtonData("2", selected = false, enabled = true, textInput = option3), + ) + + val radioButtonDataItemsError = listOf( + RadioButtonData("3", selected = false, enabled = true, textInput = option1), + RadioButtonData("4", selected = false, enabled = true, textInput = option2), + RadioButtonData("5", selected = false, enabled = true, textInput = option3), + ) + + val radioButtonDataItemsDisabled = listOf( + RadioButtonData("6", selected = true, enabled = true, textInput = option1), + RadioButtonData("7", selected = false, enabled = true, textInput = option2), + RadioButtonData("8", selected = false, enabled = true, textInput = option3), + ) + + val radioButtonDataItemsHorizontal = listOf( + RadioButtonData("9", selected = true, enabled = true, textInput = option1), + RadioButtonData("10", selected = false, enabled = true, textInput = option2), + RadioButtonData("11", selected = false, enabled = true, textInput = option3), + RadioButtonData("12", selected = false, enabled = true, textInput = option4), + RadioButtonData("13", selected = false, enabled = true, textInput = option5), + RadioButtonData("14", selected = false, enabled = true, textInput = option6), + ) + + var selectedItemVertical by remember { + mutableStateOf(radioButtonDataItemsVertical[0]) + } + + var selectedItemError by remember { + mutableStateOf(null) + } + + var selectedItemDisabled by remember { + mutableStateOf(null) + } + + var selectedItemHorizontal by remember { + mutableStateOf(radioButtonDataItemsHorizontal[0]) + } + ColumnComponentContainer("Radio Buttons") { + SubTitle("Vertical") + InputRadioButton( + title = "Label", + radioButtonData = radioButtonDataItemsVertical, + itemSelected = selectedItemVertical, + onItemChange = { + selectedItemVertical = it + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + InputRadioButton( + title = "Label", + radioButtonData = radioButtonDataItemsError, + state = InputShellState.ERROR, + itemSelected = selectedItemError, + onItemChange = { + selectedItemError = it + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + InputRadioButton( + title = "Label", + radioButtonData = radioButtonDataItemsDisabled, + state = InputShellState.DISABLED, + onItemChange = { + selectedItemDisabled = it + }, + ) + Spacer(Modifier.size(Spacing.Spacing18)) + SubTitle("Horizontal") + InputRadioButton( + title = "Label", + radioButtonData = radioButtonDataItemsHorizontal, + orientation = Orientation.HORIZONTAL, + itemSelected = selectedItemHorizontal, + onItemChange = { + selectedItemHorizontal = it + }, + ) + } +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt new file mode 100644 index 000000000..e02bb6375 --- /dev/null +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButton.kt @@ -0,0 +1,93 @@ +package org.hisp.dhis.mobile.ui.designsystem.component + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Cancel +import androidx.compose.material3.Icon +import androidx.compose.material3.RadioButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState.DISABLED +import org.hisp.dhis.mobile.ui.designsystem.component.InputShellState.UNFOCUSED +import org.hisp.dhis.mobile.ui.designsystem.component.Orientation.VERTICAL + +/** + * DHIS2 Input Radio Button. Wraps DHIS ยท [RadioButton]. + * @param title controls the text to be shown for the title + * @param radioButtonData Contains all the data that will be displayed, the list type is RadioButtonData, + * It's parameters are uid for identifying the component, selected for controlling which option is selected, + * enabled controls if the component is clickable and textInput displaying the option text. + * @param modifier allows a modifier to be passed externally + * @param orientation Controls how the radio buttons will be displayed, HORIZONTAL for rows or + * VERTICAL for columns. + * @param state Manages the InputShell state + * @param supportingText is a list of SupportingTextData that + * manages all the messages to be shown + * @param legendData manages the legendComponent + * @param isRequired controls whether the field is mandatory or not + * @param itemSelected controls which item is selected. + * @param onItemChange is a callback to notify which item has changed into the block. + */ +@Composable +fun InputRadioButton( + title: String, + radioButtonData: List, + modifier: Modifier = Modifier, + orientation: Orientation = VERTICAL, + state: InputShellState = UNFOCUSED, + supportingText: List? = null, + legendData: LegendData? = null, + isRequired: Boolean = false, + itemSelected: RadioButtonData? = null, + onItemChange: (RadioButtonData?) -> Unit, +) { + InputShell( + modifier = modifier.testTag("RADIO_BUTTON_INPUT"), + isRequiredField = isRequired, + title = title, + state = state, + legend = { + legendData?.let { + Legend(legendData, modifier.testTag("RADIO_BUTTON_INPUT_LEGEND")) + } + }, + supportingText = { + supportingText?.forEach { label -> + SupportingText( + label.text, + label.state, + modifier = modifier.testTag("RADIO_BUTTON_INPUT_SUPPORTING_TEXT"), + ) + } + }, + inputField = { + val updatedRadioButtonData = mutableListOf() + radioButtonData.forEach { + updatedRadioButtonData.add(it.copy(enabled = state != DISABLED && it.enabled)) + } + RadioButtonBlock( + orientation = orientation, + content = updatedRadioButtonData, + itemSelected = itemSelected, + onItemChange = onItemChange, + ) + }, + primaryButton = { + val isClearButtonVisible = itemSelected != null && state != DISABLED + if (isClearButtonVisible) { + IconButton( + modifier = Modifier.testTag("RADIO_BUTTON_INPUT_CLEAR_BUTTON"), + icon = { + Icon( + imageVector = Icons.Outlined.Cancel, + contentDescription = "Icon Button", + ) + }, + onClick = { + onItemChange.invoke(null) + }, + ) + } + }, + ) +} diff --git a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/RadioButton.kt b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/RadioButton.kt index c16129977..9014893e5 100644 --- a/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/RadioButton.kt +++ b/designsystem/src/commonMain/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/RadioButton.kt @@ -12,12 +12,10 @@ import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -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.platform.testTag import org.hisp.dhis.mobile.ui.designsystem.theme.InternalSizeValues import org.hisp.dhis.mobile.ui.designsystem.theme.Outline import org.hisp.dhis.mobile.ui.designsystem.theme.Ripple @@ -33,7 +31,7 @@ import org.hisp.dhis.mobile.ui.designsystem.theme.hoverPointerIcon * identifying the component, selected for controlling which option is selected, enabled controls if the component is * clickable and textInput displaying the option text. * @param onClick Will be called when the user clicks the button. - * +* */ @Composable fun RadioButton( @@ -69,7 +67,8 @@ fun RadioButton( interactionSource = interactionSource, modifier = Modifier .size(InternalSizeValues.Size40) - .hoverPointerIcon(radioButtonData.enabled), + .hoverPointerIcon(radioButtonData.enabled) + .testTag("RADIO_BUTTON_${radioButtonData.uid}"), colors = RadioButtonDefaults.colors( selectedColor = SurfaceColor.Primary, unselectedColor = Outline.Dark, @@ -108,12 +107,9 @@ fun RadioButton( fun RadioButtonBlock( orientation: Orientation, content: List, - itemSelected: RadioButtonData, + itemSelected: RadioButtonData?, onItemChange: (RadioButtonData) -> Unit, ) { - var currentItem by remember { - mutableStateOf(itemSelected) - } if (orientation == Orientation.HORIZONTAL) { FlowRowComponentsContainer( null, @@ -123,12 +119,11 @@ fun RadioButtonBlock( RadioButton( RadioButtonData( radioButtonData.uid, - if (radioButtonData.enabled) radioButtonData == currentItem else radioButtonData.selected, + if (radioButtonData.enabled) radioButtonData == itemSelected else radioButtonData.selected, radioButtonData.enabled, radioButtonData.textInput, ), ) { - currentItem = radioButtonData onItemChange.invoke(radioButtonData) } } @@ -143,12 +138,11 @@ fun RadioButtonBlock( RadioButton( RadioButtonData( radioButtonData.uid, - if (radioButtonData.enabled) radioButtonData == currentItem else radioButtonData.selected, + if (radioButtonData.enabled) radioButtonData == itemSelected else radioButtonData.selected, radioButtonData.enabled, radioButtonData.textInput, ), ) { - currentItem = radioButtonData onItemChange.invoke(radioButtonData) } } diff --git a/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButtonTest.kt b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButtonTest.kt new file mode 100644 index 000000000..8e8164115 --- /dev/null +++ b/designsystem/src/desktopTest/kotlin/org/hisp/dhis/mobile/ui/designsystem/component/InputRadioButtonTest.kt @@ -0,0 +1,245 @@ +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.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import org.hisp.dhis.mobile.ui.designsystem.theme.SurfaceColor +import org.junit.Rule +import org.junit.Test + +class InputRadioButtonTest { + + @get:Rule + val rule = createComposeRule() + + @Test + fun shouldDisplayRadioInputCorrectly() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + var selectedItem by remember { + mutableStateOf(radioButtonData[0]) + } + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + itemSelected = selectedItem, + modifier = Modifier.testTag("RADIO_BUTTON_INPUT"), + onItemChange = { + selectedItem = it + }, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_LEGEND").assertDoesNotExist() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_SUPPORTING_TEXT").assertDoesNotExist() + } + + @Test + fun shouldAllowUserSelectionWhenEnabled() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + var selectedItem by remember { + mutableStateOf(radioButtonData[0]) + } + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + modifier = Modifier.testTag("RADIO_BUTTON_INPUT"), + itemSelected = selectedItem, + onItemChange = { + selectedItem = it + }, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_1").performClick() + rule.onNodeWithTag("RADIO_BUTTON_1").assertIsSelected() + } + + @Test + fun shouldNotAllowUserSelectionWhenDisabled() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + var selectedItem by remember { + mutableStateOf(radioButtonData[0]) + } + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + modifier = Modifier.testTag("RADIO_BUTTON_INPUT"), + state = InputShellState.DISABLED, + itemSelected = selectedItem, + onItemChange = { + selectedItem = it + }, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_1").performClick() + rule.onNodeWithTag("RADIO_BUTTON_1").assertIsNotSelected() + } + + @Test + fun shouldShowClearButtonWhenItemSelected() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + var selectedItem by remember { + mutableStateOf(radioButtonData[0]) + } + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + modifier = Modifier.testTag("RADIO_BUTTON_INPUT"), + itemSelected = selectedItem, + onItemChange = { + selectedItem = it + }, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_CLEAR_BUTTON").assertExists() + } + + @Test + fun shouldHideClearButtonWhenNoItemIsSelected() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + var selectedItem by remember { + mutableStateOf(null) + } + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + modifier = Modifier.testTag("RADIO_BUTTON_INPUT"), + onItemChange = { + selectedItem = it + }, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_CLEAR_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldHideClearButtonWhenDisabled() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + var selectedItem by remember { + mutableStateOf(radioButtonData[0]) + } + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + state = InputShellState.DISABLED, + itemSelected = selectedItem, + modifier = Modifier.testTag("RADIO_BUTTON_INPUT"), + onItemChange = { + selectedItem = it + }, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_CLEAR_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldClearSelectionWhenClearButtonIsClickedAndHideClearButton() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + var selectedItem by remember { + mutableStateOf(radioButtonData[0]) + } + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + modifier = Modifier.testTag("RADIO_BUTTON_INPUT"), + itemSelected = selectedItem, + onItemChange = { + selectedItem = it + }, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_CLEAR_BUTTON").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_CLEAR_BUTTON").performClick() + rule.onNodeWithTag("RADIO_BUTTON_0").assertIsNotSelected() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_CLEAR_BUTTON").assertDoesNotExist() + } + + @Test + fun shouldShowLegendCorrectly() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + legendData = LegendData(SurfaceColor.CustomGreen, "Legend"), + onItemChange = {}, + ) + } + + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_LEGEND").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_LEGEND").assertHasClickAction() + } + + @Test + fun shouldShowSupportingTextCorrectly() { + rule.setContent { + val radioButtonData = listOf( + RadioButtonData("0", selected = false, enabled = true, textInput = "Option 1"), + RadioButtonData("1", selected = false, enabled = true, textInput = "Option 2"), + RadioButtonData("2", selected = false, enabled = true, textInput = "Option 3"), + ) + InputRadioButton( + title = "Label", + radioButtonData = radioButtonData, + supportingText = listOf(SupportingTextData("Supporting text", SupportingTextState.DEFAULT)), + onItemChange = {}, + ) + } + rule.onNodeWithTag("RADIO_BUTTON_INPUT").assertExists() + rule.onNodeWithTag("RADIO_BUTTON_INPUT_SUPPORTING_TEXT").assertExists() + } +}