BIT-666: Create UI for Secure Note-type item creation (#319)

This commit is contained in:
Oleg Semenenko 2023-12-05 12:16:26 -06:00 committed by Álison Fernandes
parent 4ce89abbbf
commit c729d7da1b
5 changed files with 996 additions and 37 deletions

View file

@ -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())
}

View file

@ -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<Text> = listOf(
"Folder 1".asText(),
"Folder 2".asText(),
"Folder 3".asText(),
),
val availableOwners: List<String> = 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()
}
}
/**

View file

@ -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,
)
},
)
}
}
}

View file

@ -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
}

View file

@ -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)