From 06384e17ab73fd190adebb1bdecd65479081e1bd Mon Sep 17 00:00:00 2001 From: Brian Yencho Date: Mon, 23 Oct 2023 10:10:58 -0500 Subject: [PATCH] BIT-698: Add landing email validation (#143) --- .../ui/auth/feature/landing/LandingScreen.kt | 8 ++ .../auth/feature/landing/LandingViewModel.kt | 29 ++++++- .../auth/feature/landing/LandingScreenTest.kt | 80 ++++++++++++++++++- .../feature/landing/LandingViewModelTest.kt | 32 ++++++-- 4 files changed, 141 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt index 19c7774b7..7a1598f66 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreen.kt @@ -41,6 +41,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect +import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenFilledButton import com.x8bit.bitwarden.ui.platform.components.BitwardenSwitch import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton @@ -67,6 +68,13 @@ fun LandingScreen( } } + BitwardenBasicDialog( + visibilityState = state.errorDialogState, + onDismissRequest = remember(viewModel) { + { viewModel.trySendAction(LandingAction.ErrorDialogDismiss) } + }, + ) + val scrollState = rememberScrollState() Column( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt index c1dd73077..2203adb90 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModel.kt @@ -3,8 +3,12 @@ package com.x8bit.bitwarden.ui.auth.feature.landing import android.os.Parcelable import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.auth.repository.AuthRepository import com.x8bit.bitwarden.ui.platform.base.BaseViewModel +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.base.util.isValidEmail +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -28,6 +32,7 @@ class LandingViewModel @Inject constructor( isContinueButtonEnabled = authRepository.rememberedEmailAddress != null, isRememberMeEnabled = authRepository.rememberedEmailAddress != null, selectedRegion = LandingState.RegionOption.BITWARDEN_US, + errorDialogState = BasicDialogState.Hidden, ), ) { @@ -42,6 +47,7 @@ class LandingViewModel @Inject constructor( when (action) { is LandingAction.ContinueButtonClick -> handleContinueButtonClicked() LandingAction.CreateAccountClick -> handleCreateAccountClicked() + is LandingAction.ErrorDialogDismiss -> handleErrorDialogDismiss() is LandingAction.RememberMeToggle -> handleRememberMeToggled(action) is LandingAction.EmailInputChanged -> handleEmailInputUpdated(action) is LandingAction.RegionOptionSelect -> handleRegionSelect(action) @@ -59,8 +65,15 @@ class LandingViewModel @Inject constructor( } private fun handleContinueButtonClicked() { - // TODO: add actual validation here: BIT-193 - if (mutableStateFlow.value.emailInput.isBlank()) { + if (!mutableStateFlow.value.emailInput.isValidEmail()) { + mutableStateFlow.update { + it.copy( + errorDialogState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ), + ) + } return } @@ -79,6 +92,12 @@ class LandingViewModel @Inject constructor( sendEvent(LandingEvent.NavigateToCreateAccount) } + private fun handleErrorDialogDismiss() { + mutableStateFlow.update { + it.copy(errorDialogState = BasicDialogState.Hidden) + } + } + private fun handleRememberMeToggled(action: LandingAction.RememberMeToggle) { mutableStateFlow.update { it.copy(isRememberMeEnabled = action.isChecked) } } @@ -101,6 +120,7 @@ data class LandingState( val isContinueButtonEnabled: Boolean, val isRememberMeEnabled: Boolean, val selectedRegion: RegionOption, + val errorDialogState: BasicDialogState, ) : Parcelable { /** * Enumerates the possible region options with their corresponding labels. @@ -143,6 +163,11 @@ sealed class LandingAction { */ data object CreateAccountClick : LandingAction() + /** + * Indicates that an error dialog is attempting to be dismissed. + */ + data object ErrorDialogDismiss : LandingAction() + /** * Indicates that the Remember Me switch has been toggled. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt index e58bdfad0..45945e1a8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingScreenTest.kt @@ -1,15 +1,23 @@ package com.x8bit.bitwarden.ui.auth.feature.landing +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsEnabled import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.filterToOne +import androidx.compose.ui.test.hasAnyAncestor +import androidx.compose.ui.test.isDialog +import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -21,7 +29,6 @@ import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals class LandingScreenTest : BaseComposeTest() { - @Test fun `continue button should be enabled or disabled according to the state`() { val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) @@ -237,12 +244,83 @@ class LandingScreenTest : BaseComposeTest() { } } + @Test + fun `error dialog should be shown or hidden according to the state`() { + val mutableStateFlow = MutableStateFlow(DEFAULT_STATE) + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns emptyFlow() + every { stateFlow } returns mutableStateFlow + } + composeTestRule.setContent { + LandingScreen( + onNavigateToCreateAccount = {}, + onNavigateToLogin = { _ -> }, + viewModel = viewModel, + ) + } + + composeTestRule.onNode(isDialog()).assertDoesNotExist() + + mutableStateFlow.update { + it.copy( + errorDialogState = BasicDialogState.Shown( + title = "Error dialog title".asText(), + message = "Error dialog message".asText(), + ), + ) + } + + composeTestRule.onNode(isDialog()).assertIsDisplayed() + + composeTestRule + .onNodeWithText("Error dialog title") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Error dialog message") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("Ok") + .assert(hasAnyAncestor(isDialog())) + .assertIsDisplayed() + } + + @Test + fun `error dialog OK click should send ErrorDialogDismiss action`() { + val viewModel = mockk(relaxed = true) { + every { eventFlow } returns emptyFlow() + every { stateFlow } returns MutableStateFlow( + DEFAULT_STATE.copy( + errorDialogState = BasicDialogState.Shown( + title = "title".asText(), + message = "message".asText(), + ), + ), + ) + every { trySendAction(LandingAction.ErrorDialogDismiss) } returns Unit + } + composeTestRule.setContent { + LandingScreen( + onNavigateToCreateAccount = {}, + onNavigateToLogin = { _ -> }, + viewModel = viewModel, + ) + } + composeTestRule + .onAllNodesWithText("Ok") + .filterToOne(hasAnyAncestor(isDialog())) + .performClick() + verify { viewModel.trySendAction(LandingAction.ErrorDialogDismiss) } + } + companion object { val DEFAULT_STATE = LandingState( emailInput = "", isContinueButtonEnabled = true, isRememberMeEnabled = false, selectedRegion = LandingState.RegionOption.BITWARDEN_US, + errorDialogState = BasicDialogState.Hidden, ) } } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt index 2abfd1113..2832f116e 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/auth/feature/landing/LandingViewModelTest.kt @@ -2,7 +2,10 @@ package com.x8bit.bitwarden.ui.auth.feature.landing import androidx.lifecycle.SavedStateHandle import app.cash.turbine.test +import com.x8bit.bitwarden.R import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.asText +import com.x8bit.bitwarden.ui.platform.components.BasicDialogState import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -49,23 +52,41 @@ class LandingViewModelTest : BaseViewModelTest() { } @Test - fun `ContinueButtonClick should emit NavigateToLogin`() = runTest { + fun `ContinueButtonClick with valid email should emit NavigateToLogin`() = runTest { + val validEmail = "email@bitwarden.com" val viewModel = createViewModel() - viewModel.trySendAction(LandingAction.EmailInputChanged("input")) + viewModel.trySendAction(LandingAction.EmailInputChanged(validEmail)) viewModel.eventFlow.test { viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick) assertEquals( - LandingEvent.NavigateToLogin("input"), + LandingEvent.NavigateToLogin(validEmail), awaitItem(), ) } } @Test - fun `ContinueButtonClick with empty input should do nothing`() = runTest { + fun `ContinueButtonClick with invalid email should display an error dialog`() = runTest { + val invalidEmail = "bitwarden.com" val viewModel = createViewModel() - viewModel.eventFlow.test { + viewModel.trySendAction(LandingAction.EmailInputChanged(invalidEmail)) + val initialState = DEFAULT_STATE.copy( + emailInput = invalidEmail, + isContinueButtonEnabled = true, + ) + viewModel.stateFlow.test { + assertEquals(initialState, awaitItem()) + viewModel.actionChannel.trySend(LandingAction.ContinueButtonClick) + assertEquals( + initialState.copy( + errorDialogState = BasicDialogState.Shown( + title = R.string.an_error_has_occurred.asText(), + message = R.string.invalid_email.asText(), + ), + ), + awaitItem(), + ) } } @@ -157,6 +178,7 @@ class LandingViewModelTest : BaseViewModelTest() { isContinueButtonEnabled = false, isRememberMeEnabled = false, selectedRegion = LandingState.RegionOption.BITWARDEN_US, + errorDialogState = BasicDialogState.Hidden, ) } }