From c729d7da1b210e520c90ff322e8d761275cdd579 Mon Sep 17 00:00:00 2001 From: Oleg Semenenko <146032743+oleg-livefront@users.noreply.github.com> Date: Tue, 5 Dec 2023 12:16:26 -0600 Subject: [PATCH] BIT-666: Create UI for Secure Note-type item creation (#319) --- .../feature/additem/VaultAddItemScreen.kt | 140 ++++++- .../feature/additem/VaultAddItemViewModel.kt | 291 +++++++++++++-- .../VaultAddSecureNotesItemTypeHandlers.kt | 87 +++++ .../feature/additem/VaultAddItemScreenTest.kt | 349 ++++++++++++++++++ .../additem/VaultAddItemViewModelTest.kt | 166 +++++++++ 5 files changed, 996 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddSecureNotesItemTypeHandlers.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt index 7839362ebc..3f09dff863 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreen.kt @@ -2,10 +2,12 @@ package com.x8bit.bitwarden.ui.vault.feature.additem import android.widget.Toast import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState @@ -55,7 +57,6 @@ fun VaultAddItemScreen( onNavigateBack: () -> Unit, viewModel: VaultAddItemViewModel = hiltViewModel(), ) { - val state by viewModel.stateFlow.collectAsStateWithLifecycle() val scrollState = rememberScrollState() val context = LocalContext.current @@ -74,10 +75,15 @@ fun VaultAddItemScreen( VaultAddLoginItemTypeHandlers.create(viewModel = viewModel) } + val secureNotesTypeHandlers = remember(viewModel) { + VaultAddSecureNotesItemTypeHandlers.create(viewModel = viewModel) + } + val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) BitwardenScaffold( modifier = Modifier + .imePadding() .fillMaxSize() .nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { @@ -139,8 +145,11 @@ fun VaultAddItemScreen( // TODO(BIT-667): Create UI for identity-type item creation } - VaultAddItemState.ItemType.SecureNotes -> { - // TODO(BIT-666): Create UI for secure notes type item creation + is VaultAddItemState.ItemType.SecureNotes -> { + AddSecureNotesTypeItemContent( + state = selectedType, + secureNotesTypeHandlers = secureNotesTypeHandlers, + ) } } } @@ -173,7 +182,7 @@ private fun TypeOptionsItem( @Suppress("LongMethod") @Composable -private fun AddLoginTypeItemContent( +private fun ColumnScope.AddLoginTypeItemContent( state: VaultAddItemState.ItemType.Login, loginItemTypeHandlers: VaultAddLoginItemTypeHandlers, ) { @@ -340,6 +349,7 @@ private fun AddLoginTypeItemContent( Spacer(modifier = Modifier.height(8.dp)) BitwardenTextField( + singleLine = false, label = stringResource(id = R.string.notes), value = state.notes, onValueChange = loginItemTypeHandlers.onNotesTextChange, @@ -384,3 +394,125 @@ private fun AddLoginTypeItemContent( Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.navigationBarsPadding()) } + +@Suppress("LongMethod") +@Composable +private fun ColumnScope.AddSecureNotesTypeItemContent( + state: VaultAddItemState.ItemType.SecureNotes, + secureNotesTypeHandlers: VaultAddSecureNotesItemTypeHandlers, +) { + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + label = stringResource(id = R.string.name), + value = state.name, + onValueChange = secureNotesTypeHandlers.onNameTextChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.miscellaneous), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + BitwardenMultiSelectButton( + label = stringResource(id = R.string.folder), + options = state.availableFolders.map { it.invoke() }.toImmutableList(), + selectedOption = state.folderName.invoke(), + onOptionSelected = secureNotesTypeHandlers.onFolderTextChange, + modifier = Modifier + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + BitwardenSwitch( + label = stringResource(id = R.string.favorite), + isChecked = state.favorite, + onCheckedChange = secureNotesTypeHandlers.onToggleFavorite, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + BitwardenSwitchWithActions( + label = stringResource(id = R.string.password_prompt), + isChecked = state.masterPasswordReprompt, + onCheckedChange = secureNotesTypeHandlers.onToggleMasterPasswordReprompt, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + actions = { + IconButton(onClick = secureNotesTypeHandlers.onTooltipClick) { + Icon( + painter = painterResource(id = R.drawable.ic_tooltip), + tint = MaterialTheme.colorScheme.onSurface, + contentDescription = stringResource( + id = R.string.master_password_re_prompt_help, + ), + ) + } + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.notes), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + BitwardenTextField( + singleLine = false, + label = stringResource(id = R.string.notes), + value = state.notes, + onValueChange = secureNotesTypeHandlers.onNotesTextChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.custom_fields), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(16.dp)) + BitwardenFilledTonalButton( + label = stringResource(id = R.string.new_custom_field), + onClick = secureNotesTypeHandlers.onAddNewCustomFieldClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(24.dp)) + BitwardenListHeaderText( + label = stringResource(id = R.string.ownership), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + + Spacer(modifier = Modifier.height(8.dp)) + BitwardenMultiSelectButton( + label = stringResource(id = R.string.who_owns_this_item), + options = state.availableOwners.toImmutableList(), + selectedOption = state.ownership, + onOptionSelected = secureNotesTypeHandlers.onOwnershipTextChange, + modifier = Modifier + .padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.navigationBarsPadding()) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt index 4e53b73dca..633c06fde3 100644 --- a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModel.kt @@ -7,9 +7,10 @@ import com.x8bit.bitwarden.R import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult 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 import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState.ItemType.Card.displayStringResId import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState.ItemType.Identity.displayStringResId -import com.x8bit.bitwarden.ui.vault.feature.additem.VaultAddItemState.ItemType.SecureNotes.displayStringResId import com.x8bit.bitwarden.ui.vault.feature.vault.util.toCipherView import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn @@ -63,6 +64,10 @@ class VaultAddItemViewModel @Inject constructor( handleAddLoginTypeAction(action) } + is VaultAddItemAction.ItemType.SecureNotesType -> { + handleAddSecureNoteTypeAction(action) + } + is VaultAddItemAction.Internal.CreateCipherResultReceive -> { handleCreateCipherResultReceive(action) } @@ -75,13 +80,29 @@ class VaultAddItemViewModel @Inject constructor( private fun handleSaveClick() { viewModelScope.launch { - sendAction( - action = VaultAddItemAction.Internal.CreateCipherResultReceive( - createCipherResult = vaultRepository.createCipher( - cipherView = stateFlow.value.selectedType.toCipherView(), - ), - ), - ) + when (state.selectedType) { + is VaultAddItemState.ItemType.Login -> { + sendAction( + action = VaultAddItemAction.Internal.CreateCipherResultReceive( + createCipherResult = vaultRepository.createCipher( + cipherView = stateFlow.value.selectedType.toCipherView(), + ), + ), + ) + } + + is VaultAddItemState.ItemType.SecureNotes -> { + // TODO Add Saving of SecureNotes (BIT-509) + } + + VaultAddItemState.ItemType.Card -> { + // TODO Add Saving of SecureNotes (BIT-668) + } + + VaultAddItemState.ItemType.Identity -> { + // TODO Add Saving of SecureNotes (BIT-508) + } + } } } @@ -112,6 +133,30 @@ class VaultAddItemViewModel @Inject constructor( } } + private fun handleSwitchToAddSecureNotesItem() { + mutableStateFlow.update { currentState -> + currentState.copy( + selectedType = VaultAddItemState.ItemType.SecureNotes(), + ) + } + } + + private fun handleSwitchToAddCardItem() { + mutableStateFlow.update { currentState -> + currentState.copy( + selectedType = VaultAddItemState.ItemType.Card, + ) + } + } + + private fun handleSwitchToAddIdentityItem() { + mutableStateFlow.update { currentState -> + currentState.copy( + selectedType = VaultAddItemState.ItemType.Identity, + ) + } + } + //endregion Type Option Handlers //region Add Login Item Type Handlers @@ -191,30 +236,6 @@ class VaultAddItemViewModel @Inject constructor( } } - private fun handleSwitchToAddCardItem() { - mutableStateFlow.update { currentState -> - currentState.copy( - selectedType = VaultAddItemState.ItemType.Card, - ) - } - } - - private fun handleSwitchToAddIdentityItem() { - mutableStateFlow.update { currentState -> - currentState.copy( - selectedType = VaultAddItemState.ItemType.Identity, - ) - } - } - - private fun handleSwitchToAddSecureNotesItem() { - mutableStateFlow.update { currentState -> - currentState.copy( - selectedType = VaultAddItemState.ItemType.SecureNotes, - ) - } - } - private fun handleNameTextInputChange( action: VaultAddItemAction.ItemType.LoginType.NameTextChange, ) { @@ -369,6 +390,114 @@ class VaultAddItemViewModel @Inject constructor( //endregion Add Login Item Type Handlers + //region Secure Note Item Type Handlers + + private fun handleAddSecureNoteTypeAction( + action: VaultAddItemAction.ItemType.SecureNotesType, + ) { + when (action) { + is VaultAddItemAction.ItemType.SecureNotesType.NameTextChange -> { + handleSecureNoteNameTextInputChange(action) + } + + is VaultAddItemAction.ItemType.SecureNotesType.FolderChange -> { + handleSecureNoteFolderTextInputChange(action) + } + + is VaultAddItemAction.ItemType.SecureNotesType.ToggleFavorite -> { + handleSecureNoteToggleFavorite(action) + } + + is VaultAddItemAction.ItemType.SecureNotesType.ToggleMasterPasswordReprompt -> { + handleSecureNoteToggleMasterPasswordReprompt(action) + } + + is VaultAddItemAction.ItemType.SecureNotesType.NotesTextChange -> { + handleSecureNoteNotesTextInputChange(action) + } + + is VaultAddItemAction.ItemType.SecureNotesType.OwnershipChange -> { + handleSecureNoteOwnershipTextInputChange(action) + } + + is VaultAddItemAction.ItemType.SecureNotesType.TooltipClick -> { + handleSecureNoteTooltipClick() + } + + is VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick -> { + handleSecureNoteAddNewCustomFieldClick() + } + } + } + + private fun handleSecureNoteNameTextInputChange( + action: VaultAddItemAction.ItemType.SecureNotesType.NameTextChange, + ) { + updateSecureNoteType { secureNoteType -> + secureNoteType.copy(name = action.name) + } + } + + private fun handleSecureNoteFolderTextInputChange( + action: VaultAddItemAction.ItemType.SecureNotesType.FolderChange, + ) { + updateSecureNoteType { secureNoteType -> + secureNoteType.copy(folderName = action.folderName) + } + } + + private fun handleSecureNoteToggleFavorite( + action: VaultAddItemAction.ItemType.SecureNotesType.ToggleFavorite, + ) { + updateSecureNoteType { secureNoteType -> + secureNoteType.copy(favorite = action.isFavorite) + } + } + + private fun handleSecureNoteToggleMasterPasswordReprompt( + action: VaultAddItemAction.ItemType.SecureNotesType.ToggleMasterPasswordReprompt, + ) { + updateSecureNoteType { secureNoteType -> + secureNoteType.copy(masterPasswordReprompt = action.isMasterPasswordReprompt) + } + } + + private fun handleSecureNoteNotesTextInputChange( + action: VaultAddItemAction.ItemType.SecureNotesType.NotesTextChange, + ) { + updateSecureNoteType { secureNoteType -> + secureNoteType.copy(notes = action.note) + } + } + + private fun handleSecureNoteOwnershipTextInputChange( + action: VaultAddItemAction.ItemType.SecureNotesType.OwnershipChange, + ) { + updateSecureNoteType { secureNoteType -> + secureNoteType.copy(ownership = action.ownership) + } + } + + private fun handleSecureNoteTooltipClick() { + // TODO Add the text for the prompt (BIT-1079) + sendEvent( + event = VaultAddItemEvent.ShowToast( + message = "Not yet implemented", + ), + ) + } + + private fun handleSecureNoteAddNewCustomFieldClick() { + // TODO Implement custom text fields (BIT-529) + sendEvent( + event = VaultAddItemEvent.ShowToast( + message = "Not yet implemented", + ), + ) + } + + //endregion Secure Notes Item Type Handlers + //region Internal Type Handlers @Suppress("MaxLineLength") @@ -408,6 +537,23 @@ class VaultAddItemViewModel @Inject constructor( } } + private inline fun updateSecureNoteType( + crossinline block: ( + VaultAddItemState.ItemType.SecureNotes, + ) -> VaultAddItemState.ItemType.SecureNotes, + ) { + mutableStateFlow.update { currentState -> + val currentSelectedType = currentState.selectedType + if (currentSelectedType !is VaultAddItemState.ItemType.SecureNotes) { + return@update currentState + } + + val updatedSecureNote = block(currentSelectedType) + + currentState.copy(selectedType = updatedSecureNote) + } + } + //endregion Utility Functions companion object { @@ -540,9 +686,28 @@ data class VaultAddItemState( * @property displayStringResId Resource ID for the display string of the secure notes type. */ @Parcelize - data object SecureNotes : ItemType() { + data class SecureNotes( + val name: String = "", + val folderName: Text = DEFAULT_FOLDER, + val favorite: Boolean = false, + val masterPasswordReprompt: Boolean = false, + val notes: String = "", + val ownership: String = DEFAULT_OWNERSHIP, + val availableFolders: List = listOf( + "Folder 1".asText(), + "Folder 2".asText(), + "Folder 3".asText(), + ), + val availableOwners: List = listOf("a@b.com", "c@d.com"), + ) : ItemType() { + override val displayStringResId: Int get() = ItemTypeOption.SECURE_NOTES.labelRes + + companion object { + private val DEFAULT_FOLDER: Text = R.string.folder_none.asText() + private const val DEFAULT_OWNERSHIP: String = "placeholder@email.com" + } } } } @@ -703,6 +868,66 @@ sealed class VaultAddItemAction { */ data object AddNewCustomFieldClick : LoginType() } + + /** + * Represents actions specific to the SecureNotes type. + */ + sealed class SecureNotesType : ItemType() { + /** + * Fired when the name text input is changed. + * + * @property name The new name text. + */ + data class NameTextChange(val name: String) : SecureNotesType() + + /** + * Fired when the folder text input is changed. + * + * @property folderName The new folder text. + */ + data class FolderChange(val folderName: Text) : SecureNotesType() + + /** + * Fired when the Favorite toggle is changed. + * + * @property isFavorite The new state of the Favorite toggle. + */ + data class ToggleFavorite(val isFavorite: Boolean) : SecureNotesType() + + /** + * Fired when the Master Password Reprompt toggle is changed. + * + * @property isMasterPasswordReprompt The new state of the Master + * Password Re-prompt toggle. + */ + data class ToggleMasterPasswordReprompt( + val isMasterPasswordReprompt: Boolean, + ) : SecureNotesType() + + /** + * Fired when the note text input is changed. + * + * @property note The new note text. + */ + data class NotesTextChange(val note: String) : SecureNotesType() + + /** + * Fired when the ownership text input is changed. + * + * @property ownership The new ownership text. + */ + data class OwnershipChange(val ownership: String) : SecureNotesType() + + /** + * Represents the action to open tooltip + */ + data object TooltipClick : SecureNotesType() + + /** + * Represents the action to add a new custom field. + */ + data object AddNewCustomFieldClick : SecureNotesType() + } } /** diff --git a/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddSecureNotesItemTypeHandlers.kt b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddSecureNotesItemTypeHandlers.kt new file mode 100644 index 0000000000..b75408901c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddSecureNotesItemTypeHandlers.kt @@ -0,0 +1,87 @@ +package com.x8bit.bitwarden.ui.vault.feature.additem + +import com.x8bit.bitwarden.ui.platform.base.util.asText + +/** + * A collection of handler functions specifically tailored for managing actions + * within the context of adding secure note items to a vault. + * + * @property onNameTextChange Handles the action when the name text is changed. + * @property onFolderTextChange Handles the action when the folder text is changed. + * @property onToggleFavorite Handles the action when the favorite toggle is changed. + * @property onToggleMasterPasswordReprompt Handles the action when the master password + * reprompt toggle is changed. + * @property onNotesTextChange Handles the action when the notes text is changed. + * @property onOwnershipTextChange Handles the action when the ownership text is changed. + * @property onTooltipClick Handles the action when the tooltip button is clicked. + * @property onAddNewCustomFieldClick Handles the action when the add new custom field + * button is clicked. + */ +@Suppress("LongParameterList") +class VaultAddSecureNotesItemTypeHandlers( + val onNameTextChange: (String) -> Unit, + val onFolderTextChange: (String) -> Unit, + val onToggleFavorite: (Boolean) -> Unit, + val onToggleMasterPasswordReprompt: (Boolean) -> Unit, + val onNotesTextChange: (String) -> Unit, + val onOwnershipTextChange: (String) -> Unit, + val onTooltipClick: () -> Unit, + val onAddNewCustomFieldClick: () -> Unit, +) { + companion object { + + /** + * Creates an instance of [VaultAddSecureNotesItemTypeHandlers] by binding actions + * to the provided [VaultAddItemViewModel]. + */ + @Suppress("LongMethod") + fun create(viewModel: VaultAddItemViewModel): VaultAddSecureNotesItemTypeHandlers { + return VaultAddSecureNotesItemTypeHandlers( + onNameTextChange = { newName -> + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.NameTextChange(newName), + ) + }, + onFolderTextChange = { newFolder -> + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.FolderChange( + newFolder.asText(), + ), + ) + }, + onToggleFavorite = { isFavorite -> + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.ToggleFavorite(isFavorite), + ) + }, + onToggleMasterPasswordReprompt = { isMasterPasswordReprompt -> + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.ToggleMasterPasswordReprompt( + isMasterPasswordReprompt, + ), + ) + }, + onNotesTextChange = { newNotes -> + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.NotesTextChange(newNotes), + ) + }, + onOwnershipTextChange = { newOwnership -> + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.OwnershipChange(newOwnership), + ) + }, + onTooltipClick = { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.TooltipClick, + ) + }, + onAddNewCustomFieldClick = { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick, + ) + }, + ) + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt index 4a0d72e8cd..213b4f72a5 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemScreenTest.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTouchInput import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest +import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.update import org.junit.Test +@Suppress("LargeClass") class VaultAddItemScreenTest : BaseComposeTest() { private val mutableStateFlow = MutableStateFlow( VaultAddItemState( @@ -625,6 +627,341 @@ class VaultAddItemScreenTest : BaseComposeTest() { .assertIsDisplayed() } + @Test + fun `in ItemType_SecureNotes state changing Name text field should trigger NameTextChange`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithText(text = "Name") + .performTextInput(text = "TestName") + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.NameTextChange(name = "TestName"), + ) + } + } + + @Test + fun `in ItemType_SecureNotes the name control should display the text provided by the state`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithText(text = "Name") + .assertTextContains("") + + mutableStateFlow.update { currentState -> + updateSecureNotesType(currentState) { copy(name = "NewName") } + } + + composeTestRule + .onNodeWithText(text = "Name") + .assertTextContains("NewName") + } + + @Test + fun `in ItemType_SecureNotes state clicking a Folder Option should send FolderChange action`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + // Opens the menu + composeTestRule + .onNodeWithContentDescription(label = "Folder, No Folder") + .performScrollTo() + .performClick() + + // Choose the option from the menu + composeTestRule + .onAllNodesWithText(text = "Folder 1") + .onLast() + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.FolderChange("Folder 1".asText()), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes the folder control should display the text provided by the state`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithContentDescription(label = "Folder, No Folder") + .performScrollTo() + .assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateSecureNotesType(currentState) { copy(folderName = "Folder 2".asText()) } + } + + composeTestRule + .onNodeWithContentDescription(label = "Folder, Folder 2") + .performScrollTo() + .assertIsDisplayed() + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes state, toggling the favorite toggle should send ToggleFavorite action`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithText("Favorite") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.ToggleFavorite( + isFavorite = true, + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes the favorite toggle should be enabled or disabled according to state`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithText("Favorite") + .assertIsOff() + + mutableStateFlow.update { currentState -> + updateSecureNotesType(currentState) { copy(favorite = true) } + } + + composeTestRule + .onNodeWithText("Favorite") + .assertIsOn() + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes state, toggling the Master password re-prompt toggle should send ToggleMasterPasswordReprompt action`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithText("Master password re-prompt") + .performScrollTo() + .performTouchInput { + click(position = Offset(x = 1f, y = center.y)) + } + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.ToggleMasterPasswordReprompt( + isMasterPasswordReprompt = true, + ), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes the master password re-prompt toggle should be enabled or disabled according to state`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithText("Master password re-prompt") + .assertIsOff() + + mutableStateFlow.update { currentState -> + updateSecureNotesType(currentState) { copy(masterPasswordReprompt = true) } + } + + composeTestRule + .onNodeWithText("Master password re-prompt") + .assertIsOn() + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes state, toggling the Master password re-prompt tooltip button should send TooltipClick action`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithContentDescription(label = "Master password re-prompt help") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.TooltipClick, + ) + } + } + + @Test + fun `in ItemType_SecureNotes state changing Notes text field should trigger NotesTextChange`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNode(hasSetTextAction() and hasText("Notes")) + .performScrollTo() + .performTextInput("TestNotes") + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.NotesTextChange("TestNotes"), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes the Notes control should display the text provided by the state`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNode(hasSetTextAction() and hasText("Notes")) + .assertTextContains("") + + mutableStateFlow.update { currentState -> + updateSecureNotesType(currentState) { copy(notes = "NewNote") } + } + + composeTestRule + .onNode(hasSetTextAction() and hasText("Notes")) + .assertTextContains("NewNote") + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes state clicking New Custom Field button should trigger AddNewCustomFieldClick`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithText(text = "New custom field") + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick, + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes state clicking a Ownership option should send OwnershipChange action`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + // Opens the menu + composeTestRule + .onNodeWithContentDescription(label = "Who owns this item?, placeholder@email.com") + .performScrollTo() + .performClick() + + // Choose the option from the menu + composeTestRule + .onAllNodesWithText(text = "a@b.com") + .onLast() + .performScrollTo() + .performClick() + + verify { + viewModel.trySendAction( + VaultAddItemAction.ItemType.SecureNotesType.OwnershipChange("a@b.com"), + ) + } + } + + @Suppress("MaxLineLength") + @Test + fun `in ItemType_SecureNotes the Ownership control should display the text provided by the state`() { + mutableStateFlow.value = + VaultAddItemState(selectedType = VaultAddItemState.ItemType.SecureNotes()) + + composeTestRule.setContent { + VaultAddItemScreen(viewModel = viewModel, onNavigateBack = {}) + } + + composeTestRule + .onNodeWithContentDescription(label = "Who owns this item?, placeholder@email.com") + .performScrollTo() + .assertIsDisplayed() + + mutableStateFlow.update { currentState -> + updateSecureNotesType(currentState) { copy(ownership = "Owner 2") } + } + + composeTestRule + .onNodeWithContentDescription(label = "Who owns this item?, Owner 2") + .performScrollTo() + .assertIsDisplayed() + } + //region Helper functions private fun updateLoginType( @@ -638,5 +975,17 @@ class VaultAddItemScreenTest : BaseComposeTest() { return currentState.copy(selectedType = updatedType) } + @Suppress("MaxLineLength") + private fun updateSecureNotesType( + currentState: VaultAddItemState, + transform: VaultAddItemState.ItemType.SecureNotes.() -> VaultAddItemState.ItemType.SecureNotes, + ): VaultAddItemState { + val updatedType = when (val currentType = currentState.selectedType) { + is VaultAddItemState.ItemType.SecureNotes -> currentType.transform() + else -> currentType + } + return currentState.copy(selectedType = updatedType) + } + //endregion Helper functions } diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt index eaee538156..033f2e3dff 100644 --- a/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt +++ b/app/src/test/java/com/x8bit/bitwarden/ui/vault/feature/additem/VaultAddItemViewModelTest.kt @@ -5,6 +5,8 @@ import app.cash.turbine.test import com.x8bit.bitwarden.data.vault.repository.VaultRepository import com.x8bit.bitwarden.data.vault.repository.model.CreateCipherResult import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest +import com.x8bit.bitwarden.ui.platform.base.util.Text +import com.x8bit.bitwarden.ui.platform.base.util.asText import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -59,6 +61,7 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { assertEquals(VaultAddItemEvent.ShowToast("Save Item Failure"), awaitItem()) } } + @Test fun `TypeOptionSelect LOGIN should switch to LoginItem`() = runTest { val viewModel = createAddVaultItemViewModel() @@ -348,6 +351,149 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { } } + @Nested + inner class VaultAddSecureNotesTypeItemActions { + private lateinit var viewModel: VaultAddItemViewModel + private lateinit var initialState: VaultAddItemState + private lateinit var initialSavedStateHandle: SavedStateHandle + + @BeforeEach + fun setup() { + initialState = createVaultAddSecureNotesItemState() + initialSavedStateHandle = createSavedStateHandleWithState(initialState) + viewModel = VaultAddItemViewModel( + savedStateHandle = initialSavedStateHandle, + vaultRepository = vaultRepository, + ) + } + + @Test + fun `NameTextChange should update name in SecureNotesItem`() = runTest { + val action = VaultAddItemAction.ItemType.SecureNotesType.NameTextChange("newName") + + viewModel.actionChannel.trySend(action) + + val expectedSecureNotesItem = + (initialState.selectedType as VaultAddItemState.ItemType.SecureNotes) + .copy(name = "newName") + + val expectedState = initialState.copy(selectedType = expectedSecureNotesItem) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `FolderChange should update folder in SecureNotesItem`() = runTest { + val action = VaultAddItemAction.ItemType.SecureNotesType.FolderChange( + "newFolder".asText(), + ) + + viewModel.actionChannel.trySend(action) + + val expectedSecureNotesItem = + (initialState.selectedType as VaultAddItemState.ItemType.SecureNotes) + .copy(folderName = "newFolder".asText()) + + val expectedState = initialState.copy(selectedType = expectedSecureNotesItem) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `ToggleFavorite should update favorite in SecureNotesItem`() = runTest { + val action = VaultAddItemAction.ItemType.SecureNotesType.ToggleFavorite(true) + + viewModel.actionChannel.trySend(action) + + val expectedSecureNotesItem = + (initialState.selectedType as VaultAddItemState.ItemType.SecureNotes) + .copy(favorite = true) + + val expectedState = initialState.copy(selectedType = expectedSecureNotesItem) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Suppress("MaxLineLength") + @Test + fun `ToggleMasterPasswordReprompt should update masterPasswordReprompt in SecureNotesItem`() = + runTest { + val action = + VaultAddItemAction.ItemType.SecureNotesType.ToggleMasterPasswordReprompt( + isMasterPasswordReprompt = true, + ) + + viewModel.actionChannel.trySend(action) + + val expectedSecureNotesItem = + (initialState.selectedType as VaultAddItemState.ItemType.SecureNotes) + .copy(masterPasswordReprompt = true) + + val expectedState = initialState.copy(selectedType = expectedSecureNotesItem) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Suppress("MaxLineLength") + @Test + fun `NotesTextChange should update notes in SecureNotesItem`() = runTest { + val action = + VaultAddItemAction.ItemType.SecureNotesType.NotesTextChange(note = "newNotes") + + viewModel.actionChannel.trySend(action) + + val expectedSecureNotesItem = + (initialState.selectedType as VaultAddItemState.ItemType.SecureNotes) + .copy(notes = "newNotes") + + val expectedState = initialState.copy(selectedType = expectedSecureNotesItem) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Suppress("MaxLineLength") + @Test + fun `OwnershipChange should update ownership in SecureNotesItem`() = runTest { + val action = + VaultAddItemAction.ItemType.SecureNotesType.OwnershipChange(ownership = "newOwner") + + viewModel.actionChannel.trySend(action) + + val expectedSecureNotesItem = + (initialState.selectedType as VaultAddItemState.ItemType.SecureNotes) + .copy(ownership = "newOwner") + + val expectedState = initialState.copy(selectedType = expectedSecureNotesItem) + + assertEquals(expectedState, viewModel.stateFlow.value) + } + + @Test + fun `TooltipClick should emit ShowToast with 'Tooltip' message`() = runTest { + viewModel.eventFlow.test { + viewModel + .actionChannel + .trySend( + VaultAddItemAction.ItemType.SecureNotesType.TooltipClick, + ) + assertEquals(VaultAddItemEvent.ShowToast("Not yet implemented"), awaitItem()) + } + } + + @Test + fun `AddNewCustomFieldClick should emit ShowToast with 'Add New Custom Field' message`() = + runTest { + viewModel.eventFlow.test { + viewModel + .actionChannel + .trySend( + VaultAddItemAction.ItemType.SecureNotesType.AddNewCustomFieldClick, + ) + assertEquals(VaultAddItemEvent.ShowToast("Not yet implemented"), awaitItem()) + } + } + } + @Suppress("LongParameterList") private fun createVaultAddLoginItemState( name: String = "", @@ -374,6 +520,26 @@ class VaultAddItemViewModelTest : BaseViewModelTest() { ), ) + @Suppress("LongParameterList") + private fun createVaultAddSecureNotesItemState( + name: String = "", + folder: Text = "No Folder".asText(), + favorite: Boolean = false, + masterPasswordReprompt: Boolean = false, + notes: String = "", + ownership: String = "placeholder@email.com", + ): VaultAddItemState = + VaultAddItemState( + selectedType = VaultAddItemState.ItemType.SecureNotes( + name = name, + folderName = folder, + favorite = favorite, + masterPasswordReprompt = masterPasswordReprompt, + notes = notes, + ownership = ownership, + ), + ) + private fun createSavedStateHandleWithState(state: VaultAddItemState) = SavedStateHandle().apply { set("state", state)