Handle delete and remove password options from SendScreen (#607)

This commit is contained in:
David Perez 2024-01-14 13:55:59 -06:00 committed by Álison Fernandes
parent 6e91332a31
commit 32c1c2155e
4 changed files with 247 additions and 11 deletions

View file

@ -27,6 +27,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.R import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect import com.x8bit.bitwarden.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.components.BasicDialogState
import com.x8bit.bitwarden.ui.platform.components.BitwardenBasicDialog
import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent import com.x8bit.bitwarden.ui.platform.components.BitwardenErrorContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingContent
import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog import com.x8bit.bitwarden.ui.platform.components.BitwardenLoadingDialog
@ -77,6 +79,9 @@ fun SendScreen(
SendDialogs( SendDialogs(
dialogState = state.dialogState, dialogState = state.dialogState,
onDismissRequest = remember(viewModel) {
{ viewModel.trySendAction(SendAction.DismissDialog) }
},
) )
val sendHandlers = remember(viewModel) { SendHandlers.create(viewModel) } val sendHandlers = remember(viewModel) { SendHandlers.create(viewModel) }
@ -176,8 +181,17 @@ fun SendScreen(
@Composable @Composable
private fun SendDialogs( private fun SendDialogs(
dialogState: SendState.DialogState?, dialogState: SendState.DialogState?,
onDismissRequest: () -> Unit,
) { ) {
when (dialogState) { when (dialogState) {
is SendState.DialogState.Error -> BitwardenBasicDialog(
visibilityState = BasicDialogState.Shown(
title = dialogState.title,
message = dialogState.message,
),
onDismissRequest = onDismissRequest,
)
is SendState.DialogState.Loading -> BitwardenLoadingDialog( is SendState.DialogState.Loading -> BitwardenLoadingDialog(
visibilityState = LoadingDialogState.Shown(dialogState.message), visibilityState = LoadingDialogState.Shown(dialogState.message),
) )

View file

@ -10,6 +10,8 @@ import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
import com.x8bit.bitwarden.ui.platform.base.util.Text import com.x8bit.bitwarden.ui.platform.base.util.Text
@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import javax.inject.Inject import javax.inject.Inject
@ -69,13 +72,64 @@ class SendViewModel @Inject constructor(
SendAction.TextTypeClick -> handleTextTypeClick() SendAction.TextTypeClick -> handleTextTypeClick()
is SendAction.DeleteSendClick -> handleDeleteSendClick(action) is SendAction.DeleteSendClick -> handleDeleteSendClick(action)
is SendAction.RemovePasswordClick -> handleRemovePasswordClick(action) is SendAction.RemovePasswordClick -> handleRemovePasswordClick(action)
SendAction.DismissDialog -> handleDismissDialog()
is SendAction.Internal -> handleInternalAction(action) is SendAction.Internal -> handleInternalAction(action)
} }
private fun handleInternalAction(action: SendAction.Internal): Unit = when (action) { private fun handleInternalAction(action: SendAction.Internal): Unit = when (action) {
is SendAction.Internal.DeleteSendResultReceive -> handleDeleteSendResultReceive(action)
is SendAction.Internal.RemovePasswordSendResultReceive -> {
handleRemovePasswordSendResultReceive(action)
}
is SendAction.Internal.SendDataReceive -> handleSendDataReceive(action) is SendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
} }
private fun handleDeleteSendResultReceive(action: SendAction.Internal.DeleteSendResultReceive) {
when (action.result) {
DeleteSendResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = SendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
)
}
}
DeleteSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(SendEvent.ShowToast(R.string.send_deleted.asText()))
}
}
}
private fun handleRemovePasswordSendResultReceive(
action: SendAction.Internal.RemovePasswordSendResultReceive,
) {
when (val result = action.result) {
is RemovePasswordSendResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = SendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = result
.errorMessage
?.asText()
?: R.string.generic_error_message.asText(),
),
)
}
}
is RemovePasswordSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(SendEvent.ShowToast(message = R.string.send_password_removed.asText()))
}
}
}
private fun handleSendDataReceive(action: SendAction.Internal.SendDataReceive) { private fun handleSendDataReceive(action: SendAction.Internal.SendDataReceive) {
when (val dataState = action.sendDataState) { when (val dataState = action.sendDataState) {
is DataState.Error -> { is DataState.Error -> {
@ -189,13 +243,31 @@ class SendViewModel @Inject constructor(
} }
private fun handleDeleteSendClick(action: SendAction.DeleteSendClick) { private fun handleDeleteSendClick(action: SendAction.DeleteSendClick) {
// TODO: Navigate to the text type send list screen (BIT-1388) mutableStateFlow.update {
sendEvent(SendEvent.ShowToast("Not yet implemented".asText())) it.copy(dialogState = SendState.DialogState.Loading(R.string.deleting.asText()))
}
viewModelScope.launch {
val result = vaultRepo.deleteSend(action.sendItem.id)
sendAction(SendAction.Internal.DeleteSendResultReceive(result))
}
} }
private fun handleRemovePasswordClick(action: SendAction.RemovePasswordClick) { private fun handleRemovePasswordClick(action: SendAction.RemovePasswordClick) {
// TODO: Navigate to the text type send list screen (BIT-1388) mutableStateFlow.update {
sendEvent(SendEvent.ShowToast("Not yet implemented".asText())) it.copy(
dialogState = SendState.DialogState.Loading(
message = R.string.removing_send_password.asText(),
),
)
}
viewModelScope.launch {
val result = vaultRepo.removePasswordSend(action.sendItem.id)
sendAction(SendAction.Internal.RemovePasswordSendResultReceive(result))
}
}
private fun handleDismissDialog() {
mutableStateFlow.update { it.copy(dialogState = null) }
} }
} }
@ -283,6 +355,15 @@ data class SendState(
*/ */
sealed class DialogState : Parcelable { sealed class DialogState : Parcelable {
/**
* Represents a dismissible dialog with the given error [message].
*/
@Parcelize
data class Error(
val title: Text?,
val message: Text,
) : DialogState()
/** /**
* Represents a loading dialog with the given [message]. * Represents a loading dialog with the given [message].
*/ */
@ -372,10 +453,27 @@ sealed class SendAction {
val sendItem: SendState.ViewState.Content.SendItem, val sendItem: SendState.ViewState.Content.SendItem,
) : SendAction() ) : SendAction()
/**
* Dismiss the currently displayed dialog.
*/
data object DismissDialog : SendAction()
/** /**
* Models actions that the [SendViewModel] itself will send. * Models actions that the [SendViewModel] itself will send.
*/ */
sealed class Internal : SendAction() { sealed class Internal : SendAction() {
/**
* Indicates a result for deleting the send has been received.
*/
data class DeleteSendResultReceive(val result: DeleteSendResult) : Internal()
/**
* Indicates a result for removing the password protection from a send has been received.
*/
data class RemovePasswordSendResultReceive(
val result: RemovePasswordSendResult,
) : Internal()
/** /**
* Indicates that the send data has been received. * Indicates that the send data has been received.
*/ */

View file

@ -589,6 +589,36 @@ class SendScreenTest : BaseComposeTest() {
composeTestRule.assertNoDialogExists() composeTestRule.assertNoDialogExists()
} }
@Test
fun `error dialog should be displayed according to state`() {
val errorMessage = "Failure"
composeTestRule.onNode(isDialog()).assertDoesNotExist()
composeTestRule.onNodeWithText(errorMessage).assertDoesNotExist()
mutableStateFlow.update {
it.copy(
dialogState = SendState.DialogState.Error(
title = null,
message = errorMessage.asText(),
),
)
}
composeTestRule
.onNodeWithText(errorMessage)
.assertIsDisplayed()
.assert(hasAnyAncestor(isDialog()))
composeTestRule
.onNodeWithText("Ok")
.assert(hasAnyAncestor(isDialog()))
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(SendAction.DismissDialog)
}
}
@Test @Test
fun `loading dialog should be displayed according to state`() { fun `loading dialog should be displayed according to state`() {
val loadingMessage = "syncing" val loadingMessage = "syncing"

View file

@ -9,11 +9,14 @@ import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.DeleteSendResult
import com.x8bit.bitwarden.data.vault.repository.model.RemovePasswordSendResult
import com.x8bit.bitwarden.data.vault.repository.model.SendData import com.x8bit.bitwarden.data.vault.repository.model.SendData
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.platform.base.util.concat import com.x8bit.bitwarden.ui.platform.base.util.concat
import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState import com.x8bit.bitwarden.ui.tools.feature.send.util.toViewState
import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
@ -108,22 +111,99 @@ class SendViewModelTest : BaseViewModelTest() {
} }
@Test @Test
fun `DeleteSendClick should emit ShowToast`() = runTest { fun `DeleteSendClick with deleteSend error should display error dialog`() = runTest {
val sendItem = mockk<SendState.ViewState.Content.SendItem>() val sendId = "sendId1234"
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
every { id } returns sendId
}
coEvery { vaultRepo.deleteSend(sendId) } returns DeleteSendResult.Error
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(SendAction.DeleteSendClick(sendItem)) viewModel.trySendAction(SendAction.DeleteSendClick(sendItem))
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem()) assertEquals(
DEFAULT_STATE.copy(
dialogState = SendState.DialogState.Loading(R.string.deleting.asText()),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
} }
} }
@Test @Test
fun `RemovePasswordClick should emit ShowToast`() = runTest { fun `DeleteSendClick with deleteSend success should emit ShowToast`() = runTest {
val sendItem = mockk<SendState.ViewState.Content.SendItem>() val sendId = "sendId1234"
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
every { id } returns sendId
}
coEvery { vaultRepo.deleteSend(sendId) } returns DeleteSendResult.Success
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.DeleteSendClick(sendItem))
assertEquals(SendEvent.ShowToast(R.string.send_deleted.asText()), awaitItem())
}
}
@Test
fun `RemovePasswordClick with removePasswordSend error should display error dialog`() =
runTest {
val sendId = "sendId1234"
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
every { id } returns sendId
}
coEvery {
vaultRepo.removePasswordSend(sendId)
} returns RemovePasswordSendResult.Error(errorMessage = null)
val viewModel = createViewModel()
viewModel.stateFlow.test {
assertEquals(DEFAULT_STATE, awaitItem())
viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem))
assertEquals(
DEFAULT_STATE.copy(
dialogState = SendState.DialogState.Loading(
message = R.string.removing_send_password.asText(),
),
),
awaitItem(),
)
assertEquals(
DEFAULT_STATE.copy(
dialogState = SendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = R.string.generic_error_message.asText(),
),
),
awaitItem(),
)
}
}
@Test
fun `RemovePasswordClick with removePasswordSend success should emit ShowToast`() = runTest {
val sendId = "sendId1234"
val sendItem = mockk<SendState.ViewState.Content.SendItem> {
every { id } returns sendId
}
coEvery {
vaultRepo.removePasswordSend(sendId)
} returns RemovePasswordSendResult.Success(mockk())
val viewModel = createViewModel() val viewModel = createViewModel()
viewModel.eventFlow.test { viewModel.eventFlow.test {
viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem)) viewModel.trySendAction(SendAction.RemovePasswordClick(sendItem))
assertEquals(SendEvent.ShowToast("Not yet implemented".asText()), awaitItem()) assertEquals(SendEvent.ShowToast(R.string.send_password_removed.asText()), awaitItem())
} }
} }
@ -206,6 +286,20 @@ class SendViewModelTest : BaseViewModelTest() {
} }
} }
@Test
fun `DismissDialog should clear the dialogState`() = runTest {
val initialState = DEFAULT_STATE.copy(
dialogState = SendState.DialogState.Error(
title = null,
message = "Test".asText(),
),
)
val viewModel = createViewModel(initialState)
viewModel.trySendAction(SendAction.DismissDialog)
assertEquals(initialState.copy(dialogState = null), viewModel.stateFlow.value)
}
@Test @Test
fun `VaultRepository SendData Error should update view state to Error`() { fun `VaultRepository SendData Error should update view state to Error`() {
val dialogState = SendState.DialogState.Loading(R.string.syncing.asText()) val dialogState = SendState.DialogState.Loading(R.string.syncing.asText())