mirror of
https://github.com/bitwarden/android.git
synced 2024-11-26 19:36:18 +03:00
BIT-503: Move item to trash from edit screen (#786)
This commit is contained in:
parent
05bdf5a25e
commit
bc3a76260f
4 changed files with 298 additions and 2 deletions
|
@ -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 },
|
||||
),
|
||||
)
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue