BIT-698: Add landing email validation (#143)

This commit is contained in:
Brian Yencho 2023-10-23 10:10:58 -05:00 committed by Álison Fernandes
parent 5a53755f4c
commit 06384e17ab
4 changed files with 141 additions and 8 deletions

View file

@ -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,

View file

@ -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.
*/

View file

@ -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<LandingViewModel>(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<LandingViewModel>(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,
)
}
}

View file

@ -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,
)
}
}