diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt index 272d35d0c0..c4b5ea7ea3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreen.kt @@ -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 }, + ), ), - ), - ) + ) + } }, ) }, diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt index 0f511839f2..b969cfc362 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModel.kt @@ -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. */ diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt index fc3102000e..656a3517ae 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditScreenTest.kt @@ -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() diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt index 0e77b2ea45..40b79750f8 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/feature/settings/folders/addedit/FolderAddEditViewModelTest.kt @@ -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(