BIT-503: Move item to trash from edit screen (#786)

This commit is contained in:
Ramsey Smith 2024-01-25 14:07:05 -07:00 committed by Álison Fernandes
parent 05bdf5a25e
commit bc3a76260f
4 changed files with 298 additions and 2 deletions

View file

@ -9,7 +9,10 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
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 androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@ -28,6 +31,7 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenOverflowActionItem
import com.x8bit.bitwarden.ui.platform.components.BitwardenScaffold
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTopAppBar
import com.x8bit.bitwarden.ui.platform.components.BitwardenTwoButtonDialog
import com.x8bit.bitwarden.ui.platform.components.LoadingDialogState
import com.x8bit.bitwarden.ui.platform.components.OverflowMenuItemData
import com.x8bit.bitwarden.ui.platform.manager.permissions.PermissionsManager
@ -43,7 +47,7 @@ import com.x8bit.bitwarden.ui.vault.feature.addedit.handlers.VaultAddEditLoginTy
* Top level composable for the vault add item screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Suppress("LongMethod")
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun VaultAddEditScreen(
onNavigateBack: () -> Unit,
@ -107,6 +111,12 @@ fun VaultAddEditScreen(
VaultAddEditCardTypeHandlers.create(viewModel = viewModel)
}
val confirmDeleteClickAction = remember(viewModel) {
{ viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick) }
}
var pendingDeleteCipher by rememberSaveable { mutableStateOf(false) }
VaultAddEditItemDialogs(
dialogState = state.dialog,
onDismissRequest = remember(viewModel) {
@ -114,6 +124,21 @@ fun VaultAddEditScreen(
},
)
if (pendingDeleteCipher) {
BitwardenTwoButtonDialog(
title = stringResource(id = R.string.delete),
message = stringResource(id = R.string.do_you_really_want_to_soft_delete_cipher),
confirmButtonText = stringResource(id = R.string.ok),
dismissButtonText = stringResource(id = R.string.cancel),
onConfirmClick = {
pendingDeleteCipher = false
confirmDeleteClickAction()
},
onDismissClick = { pendingDeleteCipher = false },
onDismissRequest = { pendingDeleteCipher = false },
)
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState())
BitwardenScaffold(
modifier = Modifier
@ -170,6 +195,11 @@ fun VaultAddEditScreen(
},
)
.takeUnless { state.isAddItemMode || !state.isCipherInCollection },
OverflowMenuItemData(
text = stringResource(id = R.string.delete),
onClick = { pendingDeleteCipher = true },
)
.takeUnless { state.isAddItemMode },
),
)
},

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.GeneratorRepository
import com.x8bit.bitwarden.data.tools.generator.repository.model.GeneratorResult
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModel
@ -147,6 +148,7 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Common.AttachmentsClick -> handleAttachmentsClick()
is VaultAddEditAction.Common.MoveToOrganizationClick -> handleMoveToOrganizationClick()
is VaultAddEditAction.Common.CollectionsClick -> handleCollectionsClick()
is VaultAddEditAction.Common.ConfirmDeleteClick -> handleConfirmDeleteClick()
is VaultAddEditAction.Common.CloseClick -> handleCloseClick()
is VaultAddEditAction.Common.DismissDialog -> handleDismissDialog()
is VaultAddEditAction.Common.SaveClick -> handleSaveClick()
@ -276,6 +278,30 @@ class VaultAddEditViewModel @Inject constructor(
onEdit { sendEvent(VaultAddEditEvent.NavigateToCollections(it.vaultItemId)) }
}
private fun handleConfirmDeleteClick() {
mutableStateFlow.update {
it.copy(
dialog = VaultAddEditState.DialogState.Loading(
R.string.soft_deleting.asText(),
),
)
}
onContent { content ->
if (content.common.originalCipher?.id != null) {
viewModelScope.launch {
trySendAction(
VaultAddEditAction.Internal.DeleteCipherReceive(
result = vaultRepository.softDeleteCipher(
cipherId = content.common.originalCipher.id.toString(),
cipherView = content.common.originalCipher,
),
),
)
}
}
}
}
private fun handleCloseClick() {
sendEvent(
event = VaultAddEditEvent.NavigateBack,
@ -848,7 +874,7 @@ class VaultAddEditViewModel @Inject constructor(
is VaultAddEditAction.Internal.UpdateCipherResultReceive -> {
handleUpdateCipherResultReceive(action)
}
is VaultAddEditAction.Internal.DeleteCipherReceive -> handleDeleteCipherReceive(action)
is VaultAddEditAction.Internal.TotpCodeReceive -> handleVaultTotpCodeReceive(action)
is VaultAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
is VaultAddEditAction.Internal.GeneratorResultReceive -> {
@ -910,6 +936,30 @@ class VaultAddEditViewModel @Inject constructor(
}
}
private fun handleDeleteCipherReceive(action: VaultAddEditAction.Internal.DeleteCipherReceive) {
when (action.result) {
DeleteCipherResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = VaultAddEditState.DialogState.Generic(
message = R.string.generic_error_message.asText(),
),
)
}
}
DeleteCipherResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(
VaultAddEditEvent.ShowToast(
message = R.string.item_soft_deleted.asText(),
),
)
sendEvent(VaultAddEditEvent.NavigateBack)
}
}
}
@Suppress("LongMethod")
private fun handleVaultDataReceive(action: VaultAddEditAction.Internal.VaultDataReceive) {
when (val vaultDataState = action.vaultDataState) {
@ -1575,6 +1625,11 @@ sealed class VaultAddEditAction {
*/
data object CollectionsClick : Common()
/**
* The user has confirmed to deleted the cipher.
*/
data object ConfirmDeleteClick : Common()
/**
* Represents the action when a type option is selected.
*
@ -1960,5 +2015,12 @@ sealed class VaultAddEditAction {
data class UpdateCipherResultReceive(
val updateCipherResult: UpdateCipherResult,
) : Internal()
/**
* Indicates that the delete cipher result has been received.
*/
data class DeleteCipherReceive(
val result: DeleteCipherResult,
) : Internal()
}
}

View file

@ -2225,6 +2225,11 @@ class VaultAddEditScreenTest : BaseComposeTest() {
.onAllNodesWithText("Move to Organization")
.filterToOne(hasAnyAncestor(isPopup()))
.assertDoesNotExist()
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
}
@Test
@ -2260,6 +2265,101 @@ class VaultAddEditScreenTest : BaseComposeTest() {
.onAllNodesWithText("Collections")
.filterToOne(hasAnyAncestor(isPopup()))
.assertDoesNotExist()
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(hasAnyAncestor(isPopup()))
.assertIsDisplayed()
}
@Test
fun `Delete dialog ok click should send ConfirmDeleteClick`() {
mutableStateFlow.update {
it.copy(
vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "mockId-1"),
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(
originalCipher = createMockCipherView(1),
),
type = VaultAddEditState.ViewState.Content.ItemType.SecureNotes,
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("More")
.performClick()
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
composeTestRule
.onAllNodesWithText("Do you really want to send to the trash?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
verify {
viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick)
}
}
@Test
fun `Delete dialog cancel click should dismiss the dialog`() {
mutableStateFlow.update {
it.copy(
vaultAddEditType = VaultAddEditType.EditItem(vaultItemId = "mockId-1"),
viewState = VaultAddEditState.ViewState.Content(
common = VaultAddEditState.ViewState.Content.Common(
originalCipher = createMockCipherView(1),
),
type = VaultAddEditState.ViewState.Content.ItemType.SecureNotes,
),
)
}
composeTestRule.assertNoDialogExists()
composeTestRule
.onNodeWithContentDescription("More")
.performClick()
composeTestRule
.onAllNodesWithText("Delete")
.filterToOne(hasAnyAncestor(isPopup()))
.performClick()
composeTestRule
.onAllNodesWithText("Do you really want to send to the trash?")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Cancel")
.filterToOne(hasAnyAncestor(isDialog()))
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Ok")
.filterToOne(hasAnyAncestor(isDialog()))
.performClick()
composeTestRule.assertNoDialogExists()
}
//region Helper functions

View file

@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.tools.generator.repository.util.FakeGeneratorRep
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockCipherView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteCipherResult
import com.x8bit.bitwarden.data.vault.repository.model.TotpCodeResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateCipherResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
@ -223,6 +224,97 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
}
}
@Test
@Suppress("MaxLineLength")
fun `ConfirmDeleteClick with DeleteCipherResult Success should emit ShowToast and NavigateBack`() =
runTest {
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val initState = createVaultAddItemState(vaultAddEditType = vaultAddEditType)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
vaultAddEditType = vaultAddEditType,
),
)
mutableVaultItemFlow.value = DataState.Loaded(data = createMockCipherView(number = 1))
coEvery {
vaultRepository.softDeleteCipher(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1),
)
} returns DeleteCipherResult.Success
viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick)
viewModel.eventFlow.test {
assertEquals(
VaultAddEditEvent.ShowToast(R.string.item_soft_deleted.asText()),
awaitItem(),
)
assertEquals(
VaultAddEditEvent.NavigateBack,
awaitItem(),
)
}
}
@Test
fun `ConfirmDeleteClick with DeleteCipherResult Failure should show generic error`() =
runTest {
val vaultAddEditType = VaultAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID)
val initState = createVaultAddItemState(vaultAddEditType = vaultAddEditType)
val viewModel = createAddVaultItemViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = initState,
vaultAddEditType = vaultAddEditType,
),
)
mutableVaultItemFlow.value = DataState.Loaded(data = createMockCipherView(number = 1))
coEvery {
vaultRepository.softDeleteCipher(
cipherId = "mockId-1",
cipherView = createMockCipherView(number = 1),
)
} returns DeleteCipherResult.Error
viewModel.trySendAction(VaultAddEditAction.Common.ConfirmDeleteClick)
assertEquals(
createVaultAddItemState(
vaultAddEditType = vaultAddEditType,
dialogState = VaultAddEditState.DialogState.Generic(
message = R.string.generic_error_message.asText(),
),
commonContentViewState = createCommonContentViewState(
name = "mockName-1",
folder = "mockId-1".asText(),
ownership = "",
originalCipher = createMockCipherView(number = 1),
availableFolders = emptyList(),
availableOwners = emptyList(),
notes = "mockNotes-1",
customFieldData = listOf(
VaultAddEditState.Custom.HiddenField(
itemId = "testId",
name = "mockName-1",
value = "mockValue-1",
),
),
),
typeContentViewState = createLoginTypeContentViewState(
username = "mockUsername-1",
password = "mockPassword-1",
uri = "www.mockuri1.com",
totpCode = "mockTotp-1",
canViewPassword = false,
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `in add mode, SaveClick should show dialog, and remove it once an item is saved`() =
runTest {
@ -1785,6 +1877,13 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
notes: String = "",
customFieldData: List<VaultAddEditState.Custom> = listOf(),
ownership: String = "placeholder@email.com",
originalCipher: CipherView? = null,
availableFolders: List<Text> = listOf(
"Folder 1".asText(),
"Folder 2".asText(),
"Folder 3".asText(),
),
availableOwners: List<String> = listOf("a@b.com", "c@d.com"),
): VaultAddEditState.ViewState.Content.Common =
VaultAddEditState.ViewState.Content.Common(
name = name,
@ -1794,6 +1893,9 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
masterPasswordReprompt = masterPasswordReprompt,
notes = notes,
ownership = ownership,
originalCipher = originalCipher,
availableFolders = availableFolders,
availableOwners = availableOwners,
)
private fun createLoginTypeContentViewState(
@ -1801,12 +1903,14 @@ class VaultAddEditViewModelTest : BaseViewModelTest() {
password: String = "",
uri: String = "",
totpCode: String? = null,
canViewPassword: Boolean = true,
): VaultAddEditState.ViewState.Content.ItemType.Login =
VaultAddEditState.ViewState.Content.ItemType.Login(
username = username,
password = password,
uri = uri,
totp = totpCode,
canViewPassword = canViewPassword,
)
private fun createSavedStateHandleWithState(