BIT-458, BIT-459: Add Folder Saving/Editing/Deleting (#832)

This commit is contained in:
Oleg Semenenko 2024-01-28 14:54:18 -06:00 committed by Álison Fernandes
parent a88f28e5bc
commit 0a6b0f8dc7
4 changed files with 557 additions and 25 deletions

View file

@ -109,14 +109,16 @@ fun FolderAddEditScreen(
{ viewModel.trySendAction(FolderAddEditAction.SaveClick) }
},
)
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.delete),
onClick = { shouldShowConfirmationDialog = true },
if (state.shouldShowOverflowMenu) {
BitwardenOverflowActionItem(
menuItemDataList = persistentListOf(
OverflowMenuItemData(
text = stringResource(id = R.string.delete),
onClick = { shouldShowConfirmationDialog = true },
),
),
),
)
)
}
},
)
},

View file

@ -3,10 +3,14 @@ package com.x8bit.bitwarden.ui.platform.feature.settings.folders.addedit
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.DateTime
import com.bitwarden.core.FolderView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult
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.asText
@ -16,6 +20,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@ -65,6 +70,14 @@ class FolderAddEditViewModel @Inject constructor(
is FolderAddEditAction.NameTextChange -> handleNameTextChange(action)
is FolderAddEditAction.SaveClick -> handleSaveClick()
is FolderAddEditAction.Internal.VaultDataReceive -> handleVaultDataReceive(action)
is FolderAddEditAction.Internal.CreateFolderResultReceive ->
handleCreateFolderResultReceive(action)
is FolderAddEditAction.Internal.UpdateFolderResultReceive ->
handleCreateFolderResultReceive(action)
is FolderAddEditAction.Internal.DeleteFolderResultReceive ->
handleDeleteResultReceive(action)
}
}
@ -72,12 +85,71 @@ class FolderAddEditViewModel @Inject constructor(
sendEvent(FolderAddEditEvent.NavigateBack)
}
private fun handleSaveClick() {
sendEvent(FolderAddEditEvent.ShowToast("Not yet implemented.".asText()))
private fun handleSaveClick() = onContent { content ->
if (content.folderName.isEmpty()) {
mutableStateFlow.update {
it.copy(
dialog = FolderAddEditState.DialogState.Error(
message = R.string.validation_field_required
.asText(R.string.name.asText()),
),
)
}
return@onContent
}
mutableStateFlow.update {
it.copy(
dialog = FolderAddEditState.DialogState.Loading(
R.string.saving.asText(),
),
)
}
viewModelScope.launch {
when (val folderAddEditType = state.folderAddEditType) {
FolderAddEditType.AddItem -> {
val result = vaultRepository.createFolder(
FolderView(
name = content.folderName,
id = folderAddEditType.folderId,
revisionDate = DateTime.now(),
),
)
sendAction(FolderAddEditAction.Internal.CreateFolderResultReceive(result))
}
is FolderAddEditType.EditItem -> {
val result = vaultRepository.updateFolder(
folderAddEditType.folderId,
FolderView(
name = content.folderName,
id = folderAddEditType.folderId,
revisionDate = DateTime.now(),
),
)
sendAction(FolderAddEditAction.Internal.UpdateFolderResultReceive(result))
}
}
}
}
private fun handleDeleteClick() {
sendEvent(FolderAddEditEvent.ShowToast("Not yet implemented.".asText()))
val folderId = state.folderAddEditType.folderId ?: return
mutableStateFlow.update {
it.copy(
dialog = FolderAddEditState.DialogState.Loading(
R.string.deleting.asText(),
),
)
}
viewModelScope.launch {
val result =
vaultRepository.deleteFolder(folderId = folderId)
sendAction(FolderAddEditAction.Internal.DeleteFolderResultReceive(result))
}
}
private fun handleDismissDialog() {
@ -160,6 +232,84 @@ class FolderAddEditViewModel @Inject constructor(
}
}
}
private fun handleCreateFolderResultReceive(
action: FolderAddEditAction.Internal.UpdateFolderResultReceive,
) {
mutableStateFlow.update {
it.copy(dialog = null)
}
when (action.result) {
is UpdateFolderResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = FolderAddEditState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is UpdateFolderResult.Success -> {
sendEvent(FolderAddEditEvent.ShowToast(R.string.folder_updated.asText()))
sendEvent(FolderAddEditEvent.NavigateBack)
}
}
}
private fun handleCreateFolderResultReceive(
action: FolderAddEditAction.Internal.CreateFolderResultReceive,
) {
mutableStateFlow.update {
it.copy(dialog = null)
}
when (action.result) {
is CreateFolderResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = FolderAddEditState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is CreateFolderResult.Success -> {
sendEvent(FolderAddEditEvent.ShowToast(R.string.folder_created.asText()))
sendEvent(FolderAddEditEvent.NavigateBack)
}
}
}
private fun handleDeleteResultReceive(
action: FolderAddEditAction.Internal.DeleteFolderResultReceive,
) {
when (action.result) {
DeleteFolderResult.Error -> {
mutableStateFlow.update {
it.copy(
dialog = FolderAddEditState.DialogState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
DeleteFolderResult.Success -> {
mutableStateFlow.update { it.copy(dialog = null) }
sendEvent(FolderAddEditEvent.ShowToast(R.string.folder_deleted.asText()))
sendEvent(event = FolderAddEditEvent.NavigateBack)
}
}
}
private inline fun onContent(
crossinline block: (FolderAddEditState.ViewState.Content) -> Unit,
) {
(state.viewState as? FolderAddEditState.ViewState.Content)?.let(block)
}
}
/**
@ -176,13 +326,19 @@ data class FolderAddEditState(
val dialog: DialogState?,
) : Parcelable {
/**
* Helper to determine whether we show the overflow menu.
*/
val shouldShowOverflowMenu: Boolean
get() = folderAddEditType is FolderAddEditType.EditItem
/**
* Helper to determine the screen display name.
*/
val screenDisplayName: Text
get() = when (folderAddEditType) {
FolderAddEditType.AddItem -> R.string.add_item.asText()
is FolderAddEditType.EditItem -> R.string.edit_item.asText()
FolderAddEditType.AddItem -> R.string.add_folder.asText()
is FolderAddEditType.EditItem -> R.string.edit_folder.asText()
}
/**
@ -289,6 +445,21 @@ sealed class FolderAddEditAction {
*/
sealed class Internal : FolderAddEditAction() {
/**
* The result for deleting a folder has been received.
*/
data class DeleteFolderResultReceive(val result: DeleteFolderResult) : Internal()
/**
* The result for updating a folder has been received.
*/
data class UpdateFolderResultReceive(val result: UpdateFolderResult) : Internal()
/**
* The result for creating a folder has been received.
*/
data class CreateFolderResultReceive(val result: CreateFolderResult) : Internal()
/**
* Indicates that the vault items data has been received.
*/

View file

@ -64,9 +64,34 @@ class FolderAddEditScreenTest : BaseComposeTest() {
}
}
@Test
fun `overflow menu should only be displayed in edit mode`() {
composeTestRule
.onNodeWithContentDescription("More")
.assertIsNotDisplayed()
mutableStateFlow.update {
DEFAULT_STATE_EDIT.copy(
viewState = FolderAddEditState.ViewState.Content(
folderName = "TestName",
),
)
}
composeTestRule
.onNodeWithContentDescription("More")
.assertIsDisplayed()
}
@Test
fun `clicking overflow menu and delete, and cancel should dismiss the dialog`() {
val deleteText = "Do you really want to delete? This cannot be undone."
mutableStateFlow.update {
DEFAULT_STATE_EDIT.copy(
viewState = FolderAddEditState.ViewState.Content(
folderName = "TestName",
),
)
}
// Open the overflow menu
composeTestRule
@ -96,6 +121,14 @@ class FolderAddEditScreenTest : BaseComposeTest() {
fun `clicking overflow menu and delete, and delete confirmation again should send a DeleteClick Action`() {
val deleteText = "Do you really want to delete? This cannot be undone."
mutableStateFlow.update {
DEFAULT_STATE_EDIT.copy(
viewState = FolderAddEditState.ViewState.Content(
folderName = "TestName",
),
)
}
composeTestRule
.onNodeWithContentDescription("More")
.performClick()

View file

@ -7,10 +7,15 @@ import com.bitwarden.core.FolderView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.DeleteFolderResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateFolderResult
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.concat
import com.x8bit.bitwarden.ui.platform.feature.settings.folders.model.FolderAddEditType
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
@ -78,13 +83,343 @@ class FolderAddEditViewModelTest : BaseViewModelTest() {
}
@Test
fun `DeleteClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
fun `DeleteClick with DeleteFolderResult Success should emit toast and navigate back`() =
runTest {
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = DEFAULT_STATE.copy(
folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)),
),
),
)
mutableFoldersStateFlow.value =
DataState.Loaded(
FolderView(
DEFAULT_EDIT_ITEM_ID,
DEFAULT_FOLDER_NAME,
DateTime.now(),
),
)
coEvery {
vaultRepository.deleteFolder(folderId = DEFAULT_EDIT_ITEM_ID)
} returns DeleteFolderResult.Success
viewModel.eventFlow.test {
viewModel.trySendAction(FolderAddEditAction.DeleteClick)
assertEquals(FolderAddEditEvent.ShowToast("Not yet implemented.".asText()), awaitItem())
viewModel.eventFlow.test {
assertEquals(
FolderAddEditEvent.ShowToast(R.string.folder_deleted.asText()),
awaitItem(),
)
assertEquals(
FolderAddEditEvent.NavigateBack,
awaitItem(),
)
}
}
@Suppress("MaxLineLength")
@Test
fun `DeleteClick with DeleteFolderResult Success should show dialog, and remove it once an item is deleted`() =
runTest {
val stateWithDialog = FolderAddEditState(
folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)),
dialog = FolderAddEditState.DialogState.Loading(
R.string.deleting.asText(),
),
viewState = FolderAddEditState.ViewState.Content(
folderName = DEFAULT_FOLDER_NAME,
),
)
val stateWithoutDialog = stateWithDialog.copy(
dialog = null,
)
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = stateWithoutDialog,
),
)
mutableFoldersStateFlow.value =
DataState.Loaded(
FolderView(
DEFAULT_EDIT_ITEM_ID,
DEFAULT_FOLDER_NAME,
DateTime.now(),
),
)
coEvery {
vaultRepository.deleteFolder(folderId = DEFAULT_EDIT_ITEM_ID)
} returns DeleteFolderResult.Success
viewModel.stateFlow.test {
viewModel.trySendAction(FolderAddEditAction.DeleteClick)
assertEquals(stateWithoutDialog, awaitItem())
assertEquals(stateWithDialog, awaitItem())
assertEquals(stateWithoutDialog, awaitItem())
}
}
@Suppress("MaxLineLength")
@Test
fun `DeleteClick should not call deleteFolder if no folderId is present`() =
runTest {
val state = FolderAddEditState(
folderAddEditType = FolderAddEditType.AddItem,
dialog = null,
viewState = FolderAddEditState.ViewState.Error(
R.string.generic_error_message.asText(),
),
)
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = state,
),
)
viewModel.trySendAction(FolderAddEditAction.DeleteClick)
coVerify(exactly = 0) {
vaultRepository.deleteFolder(any())
}
}
@Test
fun `DeleteClick with DeleteFolderResult Failure should show an error dialog`() =
runTest {
val stateWithDialog = FolderAddEditState(
folderAddEditType = FolderAddEditType.EditItem((DEFAULT_EDIT_ITEM_ID)),
dialog = FolderAddEditState.DialogState.Error(
R.string.generic_error_message.asText(),
),
viewState = FolderAddEditState.ViewState.Content(
folderName = DEFAULT_FOLDER_NAME,
),
)
val stateWithoutDialog = stateWithDialog.copy(
dialog = null,
)
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = stateWithoutDialog,
),
)
mutableFoldersStateFlow.value =
DataState.Loaded(
FolderView(
DEFAULT_EDIT_ITEM_ID,
DEFAULT_FOLDER_NAME,
DateTime.now(),
),
)
coEvery {
vaultRepository.deleteFolder(folderId = DEFAULT_EDIT_ITEM_ID)
} returns DeleteFolderResult.Error
viewModel.trySendAction(FolderAddEditAction.DeleteClick)
assertEquals(
stateWithDialog,
viewModel.stateFlow.value,
)
}
@Test
fun `SaveClick with empty name should show an error dialog`() =
runTest {
val stateWithoutName = FolderAddEditState(
folderAddEditType = FolderAddEditType.AddItem,
dialog = null,
viewState = FolderAddEditState.ViewState.Content(
folderName = "",
),
)
val stateWithDialog = stateWithoutName.copy(
dialog = FolderAddEditState.DialogState.Error(
R.string.validation_field_required
.asText(R.string.name.asText()),
),
)
val viewModel = createViewModel(
createSavedStateHandleWithState(
state = stateWithoutName,
),
)
assertEquals(stateWithoutName, viewModel.stateFlow.value)
viewModel.trySendAction(FolderAddEditAction.SaveClick)
assertEquals(stateWithDialog, viewModel.stateFlow.value)
}
@Suppress("MaxLineLength")
@Test
fun `in add mode, SaveClick createFolder success should show dialog, and remove it once an item is saved`() =
runTest {
val stateWithDialog = FolderAddEditState(
folderAddEditType = FolderAddEditType.AddItem,
dialog = FolderAddEditState.DialogState.Loading(
R.string.saving.asText(),
),
viewState = FolderAddEditState.ViewState.Content(
folderName = DEFAULT_FOLDER_NAME,
),
)
val stateWithoutDialog = stateWithDialog.copy(
dialog = null,
)
val viewModel = createViewModel(
createSavedStateHandleWithState(
state = stateWithoutDialog,
),
)
coEvery {
vaultRepository.createFolder(any())
} returns CreateFolderResult.Success(mockk())
viewModel.stateFlow.test {
viewModel.trySendAction(FolderAddEditAction.SaveClick)
assertEquals(stateWithoutDialog, awaitItem())
assertEquals(stateWithDialog, awaitItem())
assertEquals(stateWithoutDialog, awaitItem())
}
}
@Test
fun `in add mode, SaveClick createFolder error should show an error dialog`() = runTest {
val state = FolderAddEditState(
folderAddEditType = FolderAddEditType.AddItem,
dialog = null,
viewState = FolderAddEditState.ViewState.Content(
folderName = DEFAULT_FOLDER_NAME,
),
)
val viewModel = createViewModel(
createSavedStateHandleWithState(
state = state,
),
)
coEvery {
vaultRepository.createFolder(any())
} returns CreateFolderResult.Error
viewModel.trySendAction(FolderAddEditAction.SaveClick)
assertEquals(
state.copy(
dialog = FolderAddEditState.DialogState.Error(
R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
fun `in edit mode, SaveClick should show dialog, and remove it once an item is saved`() =
runTest {
val stateWithDialog = FolderAddEditState(
folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID),
dialog = FolderAddEditState.DialogState.Loading(
R.string.saving.asText(),
),
viewState = FolderAddEditState.ViewState.Content(
folderName = DEFAULT_FOLDER_NAME,
),
)
val stateWithoutDialog = stateWithDialog.copy(
folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID),
dialog = null,
viewState = FolderAddEditState.ViewState.Content(
folderName = DEFAULT_FOLDER_NAME,
),
)
val viewModel = createViewModel(
savedStateHandle = createSavedStateHandleWithState(
state = stateWithoutDialog,
),
)
mutableFoldersStateFlow.value =
DataState.Loaded(
FolderView(
DEFAULT_EDIT_ITEM_ID,
DEFAULT_FOLDER_NAME,
DateTime.now(),
),
)
coEvery {
vaultRepository.updateFolder(any(), any())
} returns UpdateFolderResult.Success(mockk())
viewModel.stateFlow.test {
viewModel.trySendAction(FolderAddEditAction.SaveClick)
assertEquals(stateWithoutDialog, awaitItem())
assertEquals(stateWithDialog, awaitItem())
assertEquals(stateWithoutDialog, awaitItem())
}
}
@Test
fun `in edit mode, SaveClick updateFolder error should show an error dialog`() = runTest {
val state = FolderAddEditState(
folderAddEditType = FolderAddEditType.EditItem(DEFAULT_EDIT_ITEM_ID),
dialog = null,
viewState = FolderAddEditState.ViewState.Content(
folderName = DEFAULT_FOLDER_NAME,
),
)
val viewModel = createViewModel(
createSavedStateHandleWithState(
state = state,
),
)
mutableFoldersStateFlow.value =
DataState.Loaded(
FolderView(
DEFAULT_EDIT_ITEM_ID,
DEFAULT_FOLDER_NAME,
DateTime.now(),
),
)
coEvery {
vaultRepository.updateFolder(any(), any())
} returns UpdateFolderResult.Error(errorMessage = null)
viewModel.trySendAction(FolderAddEditAction.SaveClick)
assertEquals(
state.copy(
dialog = FolderAddEditState.DialogState.Error(
R.string.generic_error_message.asText(),
),
),
viewModel.stateFlow.value,
)
}
@Test
@ -121,15 +456,6 @@ class FolderAddEditViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `SaveClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
viewModel.trySendAction(FolderAddEditAction.SaveClick)
assertEquals(FolderAddEditEvent.ShowToast("Not yet implemented.".asText()), awaitItem())
}
}
@Test
fun `folderStateFlow Error should update state to error`() {
val viewModel = createViewModel(