Modify Add Sends UI to allow for editing existing sends (#575)

This commit is contained in:
David Perez 2024-01-11 15:44:47 -06:00 committed by Álison Fernandes
parent 7e0a14d3a0
commit 9e6c49fb7c
10 changed files with 677 additions and 55 deletions

View file

@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
@ -36,9 +38,11 @@ import com.x8bit.bitwarden.ui.platform.components.BitwardenListHeaderText
import com.x8bit.bitwarden.ui.platform.components.BitwardenPasswordField
import com.x8bit.bitwarden.ui.platform.components.BitwardenSegmentedButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenStepper
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextButton
import com.x8bit.bitwarden.ui.platform.components.BitwardenTextField
import com.x8bit.bitwarden.ui.platform.components.BitwardenWideSwitch
import com.x8bit.bitwarden.ui.platform.components.SegmentedButtonState
import com.x8bit.bitwarden.ui.platform.theme.LocalNonMaterialTypography
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandlers
/**
@ -48,6 +52,7 @@ import com.x8bit.bitwarden.ui.tools.feature.send.addsend.handlers.AddSendHandler
@Composable
fun AddSendContent(
state: AddSendState.ViewState.Content,
isAddMode: Boolean,
addSendHandlers: AddSendHandlers,
modifier: Modifier = Modifier,
) {
@ -65,6 +70,7 @@ fun AddSendContent(
onValueChange = addSendHandlers.onNamChange,
)
if (isAddMode) {
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.type),
@ -82,17 +88,18 @@ fun AddSendContent(
SegmentedButtonState(
text = stringResource(id = R.string.file),
onClick = addSendHandlers.onFileTypeSelect,
isChecked = state.selectedType is AddSendState.ViewState.Content.SendType.File,
isChecked = state.isFileType,
),
SegmentedButtonState(
text = stringResource(id = R.string.text),
onClick = addSendHandlers.onTextTypeSelect,
isChecked = state.selectedType is AddSendState.ViewState.Content.SendType.Text,
isChecked = state.isTextType,
),
),
)
}
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(8.dp))
when (val type = state.selectedType) {
is AddSendState.ViewState.Content.SendType.File -> {
BitwardenListHeaderText(
@ -161,6 +168,7 @@ fun AddSendContent(
Spacer(modifier = Modifier.height(16.dp))
AddSendOptions(
state = state,
isAddMode = isAddMode,
addSendHandlers = addSendHandlers,
)
@ -173,12 +181,15 @@ fun AddSendContent(
* Displays a collapsable set of new send options.
*
* @param state The content state.
* @param isAddMode When `true`, indicates that we are creating a new send and `false` when editing
* an existing send.
* @param addSendHandlers THe handlers various events.
*/
@Suppress("LongMethod")
@Composable
private fun AddSendOptions(
state: AddSendState.ViewState.Content,
isAddMode: Boolean,
addSendHandlers: AddSendHandlers,
) {
var isExpanded by rememberSaveable { mutableStateOf(false) }
@ -221,6 +232,7 @@ private fun AddSendOptions(
modifier = Modifier.clipToBounds(),
) {
Column {
if (isAddMode) {
SendDeletionDateChooser(
modifier = Modifier
.fillMaxWidth()
@ -240,6 +252,72 @@ private fun AddSendOptions(
currentZonedDateTime = state.common.expirationDate,
onDateSelect = addSendHandlers.onExpirationDateChange,
)
} else {
BitwardenListHeaderText(
label = stringResource(id = R.string.deletion_date),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
AddSendCustomDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.deletionDate,
onDateSelect = { addSendHandlers.onDeletionDateChange(requireNotNull(it)) },
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(id = R.string.deletion_date_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(16.dp))
BitwardenListHeaderText(
label = stringResource(id = R.string.expiration_date),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
Spacer(modifier = Modifier.height(8.dp))
AddSendCustomDateChooser(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
dateFormatPattern = state.common.dateFormatPattern,
timeFormatPattern = state.common.timeFormatPattern,
currentZonedDateTime = state.common.expirationDate,
onDateSelect = addSendHandlers.onExpirationDateChange,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(id = R.string.expiration_date_info),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Spacer(modifier = Modifier.width(4.dp))
BitwardenTextButton(
label = stringResource(id = R.string.clear),
onClick = addSendHandlers.onClearExpirationDateClick,
isEnabled = state.common.expirationDate != null,
modifier = Modifier.wrapContentWidth(),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
BitwardenStepper(
label = stringResource(id = R.string.maximum_access_count),
@ -260,6 +338,30 @@ private fun AddSendOptions(
.fillMaxWidth()
.padding(horizontal = 32.dp),
)
if (!isAddMode) {
state.common.currentAccessCount?.let {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
) {
Text(
text = stringResource(id = R.string.current_access_count) + ":",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = it.toString(),
style = LocalNonMaterialTypography.current.bodySmallProminent,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(16.dp))
}
}
Spacer(modifier = Modifier.height(8.dp))
BitwardenPasswordField(
label = stringResource(id = R.string.new_password),

View file

@ -135,6 +135,7 @@ fun AddSendScreen(
when (val viewState = state.viewState) {
is AddSendState.ViewState.Content -> AddSendContent(
state = viewState,
isAddMode = state.isAddMode,
addSendHandlers = remember(viewModel) { AddSendHandlers.create(viewModel) },
modifier = modifier,
)

View file

@ -3,18 +3,24 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.util.baseWebSendUrl
import com.x8bit.bitwarden.data.platform.repository.util.takeUntilLoaded
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
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.platform.base.util.concat
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toViewState
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.launchIn
@ -50,6 +56,7 @@ class AddSendViewModel @Inject constructor(
AddSendType.AddItem -> AddSendState.ViewState.Content(
common = AddSendState.ViewState.Content.Common(
name = "",
currentAccessCount = null,
maxAccessCount = null,
passwordInput = "",
noteInput = "",
@ -62,6 +69,7 @@ class AddSendViewModel @Inject constructor(
.truncatedTo(ChronoUnit.DAYS)
.plusWeeks(1),
expirationDate = null,
sendUrl = null,
),
selectedType = AddSendState.ViewState.Content.SendType.Text(
input = "",
@ -69,9 +77,7 @@ class AddSendViewModel @Inject constructor(
),
)
is AddSendType.EditItem -> AddSendState.ViewState.Error(
"Not yet implemented".asText(),
)
is AddSendType.EditItem -> AddSendState.ViewState.Loading
},
dialogState = null,
isPremiumUser = authRepo.userStateFlow.value?.activeAccount?.isPremium == true,
@ -85,6 +91,19 @@ class AddSendViewModel @Inject constructor(
.onEach { savedStateHandle[KEY_STATE] = it }
.launchIn(viewModelScope)
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> Unit
is AddSendType.EditItem -> {
vaultRepo
.getSendStateFlow(addSendType.sendItemId)
// We'll stop getting updates as soon as we get some loaded data.
.takeUntilLoaded()
.map { AddSendAction.Internal.SendDataReceive(it) }
.onEach(::sendAction)
.launchIn(viewModelScope)
}
}
authRepo
.userStateFlow
.map { AddSendAction.Internal.UserStateReceive(it) }
@ -100,6 +119,7 @@ class AddSendViewModel @Inject constructor(
is AddSendAction.CloseClick -> handleCloseClick()
is AddSendAction.DeletionDateChange -> handleDeletionDateChange(action)
is AddSendAction.ExpirationDateChange -> handleExpirationDateChange(action)
AddSendAction.ClearExpirationDate -> handleClearExpirationDate()
AddSendAction.DismissDialogClick -> handleDismissDialogClick()
is AddSendAction.SaveClick -> handleSaveClick()
is AddSendAction.FileTypeClick -> handleFileTypeClick()
@ -118,7 +138,9 @@ class AddSendViewModel @Inject constructor(
private fun handleInternalAction(action: AddSendAction.Internal): Unit = when (action) {
is AddSendAction.Internal.CreateSendResultReceive -> handleCreateSendResultReceive(action)
is AddSendAction.Internal.UpdateSendResultReceive -> handleUpdateSendResultReceive(action)
is AddSendAction.Internal.UserStateReceive -> handleUserStateReceive(action)
is AddSendAction.Internal.SendDataReceive -> handleSendDataReceive(action)
}
private fun handleCreateSendResultReceive(
@ -148,12 +170,113 @@ class AddSendViewModel @Inject constructor(
}
}
private fun handleUpdateSendResultReceive(
action: AddSendAction.Internal.UpdateSendResultReceive,
) {
when (val result = action.result) {
is UpdateSendResult.Error -> {
mutableStateFlow.update {
it.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = result
.errorMessage
?.asText()
?: R.string.generic_error_message.asText(),
),
)
}
}
is UpdateSendResult.Success -> {
mutableStateFlow.update { it.copy(dialogState = null) }
sendEvent(AddSendEvent.NavigateBack)
sendEvent(
AddSendEvent.ShowShareSheet(
message = result.sendView.toSendUrl(state.baseWebSendUrl),
),
)
}
}
}
private fun handleUserStateReceive(action: AddSendAction.Internal.UserStateReceive) {
mutableStateFlow.update {
it.copy(isPremiumUser = action.userState?.activeAccount?.isPremium == true)
}
}
@Suppress("LongMethod")
private fun handleSendDataReceive(action: AddSendAction.Internal.SendDataReceive) {
when (val sendDataState = action.sendDataState) {
is DataState.Error -> {
mutableStateFlow.update {
it.copy(
viewState = AddSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
is DataState.Loaded -> {
mutableStateFlow.update {
it.copy(
viewState = sendDataState
.data
?.toViewState(
clock = clock,
baseWebSendUrl = environmentRepo
.environment
.environmentUrlData
.baseWebSendUrl,
)
?: AddSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
DataState.Loading -> {
mutableStateFlow.update {
it.copy(viewState = AddSendState.ViewState.Loading)
}
}
is DataState.NoNetwork -> {
mutableStateFlow.update {
it.copy(
viewState = AddSendState.ViewState.Error(
message = R.string.internet_connection_required_title
.asText()
.concat(R.string.internet_connection_required_message.asText()),
),
)
}
}
is DataState.Pending -> {
mutableStateFlow.update {
it.copy(
viewState = sendDataState
.data
?.toViewState(
clock = clock,
baseWebSendUrl = environmentRepo
.environment
.environmentUrlData
.baseWebSendUrl,
)
?: AddSendState.ViewState.Error(
message = R.string.generic_error_message.asText(),
),
)
}
}
}
}
private fun handleCopyLinkClick() {
// TODO Add copy link support (BIT-1435)
sendEvent(AddSendEvent.ShowToast("Not yet implemented"))
@ -212,6 +335,10 @@ class AddSendViewModel @Inject constructor(
}
}
private fun handleClearExpirationDate() {
updateCommonContent { it.copy(expirationDate = null) }
}
private fun handleSaveClick() {
onContent { content ->
if (content.common.name.isBlank()) {
@ -235,9 +362,21 @@ class AddSendViewModel @Inject constructor(
)
}
viewModelScope.launch {
when (val addSendType = state.addSendType) {
AddSendType.AddItem -> {
val result = vaultRepo.createSend(content.toSendView(clock))
sendAction(AddSendAction.Internal.CreateSendResultReceive(result))
}
is AddSendType.EditItem -> {
val result = vaultRepo.updateSend(
sendId = addSendType.sendItemId,
sendView = content.toSendView(clock),
)
sendAction(AddSendAction.Internal.UpdateSendResultReceive(result))
}
}
}
}
}
@ -402,12 +541,23 @@ data class AddSendState(
val selectedType: SendType,
) : ViewState() {
/**
* Helper method to indicate if the selected type is [SendType.File].
*/
val isFileType: Boolean get() = selectedType is SendType.File
/**
* Helper method to indicate if the selected type is [SendType.Text].
*/
val isTextType: Boolean get() = selectedType is SendType.Text
/**
* Content data that is common for all item types.
*/
@Parcelize
data class Common(
val name: String,
val currentAccessCount: Int?,
val maxAccessCount: Int?,
val passwordInput: String,
val noteInput: String,
@ -415,6 +565,7 @@ data class AddSendState(
val isDeactivateChecked: Boolean,
val deletionDate: ZonedDateTime,
val expirationDate: ZonedDateTime?,
val sendUrl: String?,
) : Parcelable {
val dateFormatPattern: String get() = "M/d/yyyy"
@ -592,6 +743,11 @@ sealed class AddSendAction {
*/
data class ExpirationDateChange(val expirationDate: ZonedDateTime?) : AddSendAction()
/**
* The user has cleared the expiration date.
*/
data object ClearExpirationDate : AddSendAction()
/**
* Models actions that the [AddSendViewModel] itself might send.
*/
@ -605,5 +761,15 @@ sealed class AddSendAction {
* Indicates a result for creating a send has been received.
*/
data class CreateSendResultReceive(val result: CreateSendResult) : Internal()
/**
* Indicates a result for updating a send has been received.
*/
data class UpdateSendResultReceive(val result: UpdateSendResult) : Internal()
/**
* Indicates that the send item data has been received.
*/
data class SendDataReceive(val sendDataState: DataState<SendView?>) : Internal()
}
}

View file

@ -22,6 +22,7 @@ data class AddSendHandlers(
val onDeactivateSendToggle: (Boolean) -> Unit,
val onDeletionDateChange: (ZonedDateTime) -> Unit,
val onExpirationDateChange: (ZonedDateTime?) -> Unit,
val onClearExpirationDateClick: () -> Unit,
) {
companion object {
/**
@ -57,6 +58,9 @@ data class AddSendHandlers(
onExpirationDateChange = {
viewModel.trySendAction(AddSendAction.ExpirationDateChange(it))
},
onClearExpirationDateClick = {
viewModel.trySendAction(AddSendAction.ClearExpirationDate)
},
)
}
}

View file

@ -0,0 +1,42 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util
import com.bitwarden.core.SendType
import com.bitwarden.core.SendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import java.time.Clock
import java.time.ZonedDateTime
/**
* Transforms [SendView] into [AddSendState.ViewState.Content].
*/
fun SendView.toViewState(
clock: Clock,
baseWebSendUrl: String,
): AddSendState.ViewState.Content =
AddSendState.ViewState.Content(
common = AddSendState.ViewState.Content.Common(
name = this.name,
currentAccessCount = this.accessCount.toInt(),
maxAccessCount = this.maxAccessCount?.toInt(),
// We do not set the password here
// We only allow them to create new passwords, not view old ones
passwordInput = "",
noteInput = this.notes.orEmpty(),
isHideEmailChecked = this.hideEmail,
isDeactivateChecked = this.disabled,
deletionDate = ZonedDateTime.ofInstant(this.deletionDate, clock.zone),
expirationDate = this.expirationDate?.let { ZonedDateTime.ofInstant(it, clock.zone) },
sendUrl = this.toSendUrl(baseWebSendUrl),
),
selectedType = when (type) {
SendType.TEXT -> {
AddSendState.ViewState.Content.SendType.Text(
input = this.text?.text.orEmpty(),
isHideByDefaultChecked = this.text?.hidden == true,
)
}
SendType.FILE -> AddSendState.ViewState.Content.SendType.File
},
)

View file

@ -2,6 +2,9 @@ package com.x8bit.bitwarden.ui.tools.feature.send.addsend
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertTextEquals
@ -22,6 +25,7 @@ import com.x8bit.bitwarden.ui.platform.base.BaseComposeTest
import com.x8bit.bitwarden.ui.platform.base.util.IntentHandler
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType
import com.x8bit.bitwarden.ui.util.isEditableText
import com.x8bit.bitwarden.ui.util.isProgressBar
import io.mockk.every
import io.mockk.just
@ -35,6 +39,7 @@ import org.junit.Before
import org.junit.Test
import java.time.ZonedDateTime
@Suppress("LargeClass")
class AddSendScreenTest : BaseComposeTest() {
private var onNavigateBackCalled = false
@ -229,6 +234,41 @@ class AddSendScreenTest : BaseComposeTest() {
)
}
@Test
fun `segmented buttons should appear based on state`() {
mutableStateFlow.update { it.copy(addSendType = AddSendType.AddItem) }
composeTestRule
.onNodeWithText("Type")
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("File")
.filterToOne(!isEditableText)
.performScrollTo()
.assertIsDisplayed()
composeTestRule
.onAllNodesWithText("Text")
.filterToOne(!isEditableText)
.performScrollTo()
.assertIsDisplayed()
mutableStateFlow.update {
it.copy(addSendType = AddSendType.EditItem(sendItemId = "sendId"))
}
composeTestRule
.onNodeWithText("Type")
.assertIsNotDisplayed()
composeTestRule
.onAllNodesWithText("File")
.filterToOne(!isEditableText)
.assertIsNotDisplayed()
composeTestRule
.onAllNodesWithText("Text")
.filterToOne(!isEditableText)
.assertIsNotDisplayed()
}
@Test
fun `File segmented button click should send FileTypeClick`() {
composeTestRule
@ -594,6 +634,64 @@ class AddSendScreenTest : BaseComposeTest() {
.assertIsOn()
}
@Test
fun `in edit mode, clear button should be enabled based on state`() {
mutableStateFlow.update {
it.copy(addSendType = AddSendType.EditItem(sendItemId = "sendId"))
}
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Clear")
.performScrollTo()
.assertIsNotEnabled()
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
),
),
)
}
composeTestRule
.onNodeWithText("Clear")
.performScrollTo()
.assertIsEnabled()
}
@Test
fun `in edit mode, clear button should send ClearExpirationDate`() {
mutableStateFlow.update {
it.copy(
addSendType = AddSendType.EditItem(sendItemId = "sendId"),
viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
),
),
)
}
composeTestRule
.onNodeWithText("Options")
.performScrollTo()
.performClick()
composeTestRule
.onNodeWithText("Clear")
.performScrollTo()
.performClick()
verify(exactly = 1) {
viewModel.trySendAction(AddSendAction.ClearExpirationDate)
}
}
@Test
fun `progressbar should be displayed according to state`() {
mutableStateFlow.update {
@ -683,6 +781,7 @@ class AddSendScreenTest : BaseComposeTest() {
companion object {
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "",
currentAccessCount = null,
maxAccessCount = null,
passwordInput = "",
noteInput = "",
@ -690,6 +789,7 @@ class AddSendScreenTest : BaseComposeTest() {
isDeactivateChecked = false,
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
expirationDate = null,
sendUrl = null,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(

View file

@ -7,13 +7,17 @@ import com.x8bit.bitwarden.R
import com.x8bit.bitwarden.data.auth.repository.AuthRepository
import com.x8bit.bitwarden.data.auth.repository.model.UserState
import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository
import com.x8bit.bitwarden.data.platform.repository.model.DataState
import com.x8bit.bitwarden.data.platform.repository.model.Environment
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.data.vault.repository.VaultRepository
import com.x8bit.bitwarden.data.vault.repository.model.CreateSendResult
import com.x8bit.bitwarden.data.vault.repository.model.UpdateSendResult
import com.x8bit.bitwarden.ui.platform.base.BaseViewModelTest
import com.x8bit.bitwarden.ui.platform.base.util.asText
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.model.AddSendType
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.toViewState
import com.x8bit.bitwarden.ui.tools.feature.send.util.toSendUrl
import io.mockk.coEvery
import io.mockk.coVerify
@ -45,18 +49,27 @@ class AddSendViewModelTest : BaseViewModelTest() {
private val environmentRepository: EnvironmentRepository = mockk {
every { environment } returns Environment.Us
}
private val vaultRepository: VaultRepository = mockk()
private val mutableSendDataStateFlow = MutableStateFlow<DataState<SendView>>(DataState.Loading)
private val vaultRepository: VaultRepository = mockk {
every { getSendStateFlow(any()) } returns mutableSendDataStateFlow
}
@BeforeEach
fun setup() {
mockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH)
mockkStatic(SEND_VIEW_EXTENSIONS_PATH)
mockkStatic(
ADD_SEND_STATE_EXTENSIONS_PATH,
ADD_SEND_VIEW_EXTENSIONS_PATH,
SEND_VIEW_EXTENSIONS_PATH,
)
}
@AfterEach
fun tearDown() {
unmockkStatic(ADD_SEND_STATE_EXTENSIONS_PATH)
unmockkStatic(SEND_VIEW_EXTENSIONS_PATH)
unmockkStatic(
ADD_SEND_STATE_EXTENSIONS_PATH,
ADD_SEND_VIEW_EXTENSIONS_PATH,
SEND_VIEW_EXTENSIONS_PATH,
)
}
@Test
@ -149,6 +162,90 @@ class AddSendViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `SaveClick with updateSend success should emit NavigateBack and ShowShareSheet`() =
runTest {
val sendId = "sendId-1"
val viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(name = "input"),
)
val initialState = DEFAULT_STATE.copy(
addSendType = AddSendType.EditItem(sendId),
viewState = viewState,
)
val mockSendView = createMockSendView(number = 1)
every { mockSendView.toViewState(clock, DEFAULT_ENVIRONMENT_URL) } returns viewState
every { viewState.toSendView(clock) } returns mockSendView
val sendUrl = "www.test.com/send/test"
val resultSendView = mockk<SendView> {
every { toSendUrl(DEFAULT_ENVIRONMENT_URL) } returns sendUrl
every { id } returns sendId
}
coEvery {
vaultRepository.updateSend(sendId = sendId, sendView = mockSendView)
} returns UpdateSendResult.Success(sendView = resultSendView)
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
val viewModel = createViewModel(initialState, AddSendType.EditItem(sendId))
viewModel.eventFlow.test {
viewModel.trySendAction(AddSendAction.SaveClick)
assertEquals(AddSendEvent.NavigateBack, awaitItem())
assertEquals(AddSendEvent.ShowShareSheet(sendUrl), awaitItem())
}
assertEquals(initialState, viewModel.stateFlow.value)
coVerify(exactly = 1) {
vaultRepository.updateSend(sendId = sendId, sendView = mockSendView)
}
}
@Test
fun `SaveClick with updateSend failure should show error dialog`() = runTest {
val sendId = "sendId-1"
val viewState = DEFAULT_VIEW_STATE.copy(
common = DEFAULT_COMMON_STATE.copy(name = "input"),
)
val initialState = DEFAULT_STATE.copy(
addSendType = AddSendType.EditItem(sendId),
viewState = viewState,
)
val mockSendView = mockk<SendView> {
every { id } returns sendId
}
val errorMessage = "Failure"
every { mockSendView.toViewState(clock, DEFAULT_ENVIRONMENT_URL) } returns viewState
every { viewState.toSendView(clock) } returns mockSendView
coEvery {
vaultRepository.updateSend(sendId = sendId, sendView = mockSendView)
} returns UpdateSendResult.Error(errorMessage = errorMessage)
mutableSendDataStateFlow.value = DataState.Loaded(mockSendView)
val viewModel = createViewModel(initialState, AddSendType.EditItem(sendId))
viewModel.stateFlow.test {
assertEquals(initialState, awaitItem())
viewModel.trySendAction(AddSendAction.SaveClick)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Loading(
message = R.string.saving.asText(),
),
),
awaitItem(),
)
assertEquals(
initialState.copy(
dialogState = AddSendState.DialogState.Error(
title = R.string.an_error_has_occurred.asText(),
message = errorMessage.asText(),
),
),
awaitItem(),
)
}
coVerify(exactly = 1) {
vaultRepository.updateSend(sendId = sendId, sendView = mockSendView)
}
}
@Test
fun `SaveClick with blank name should show error dialog`() {
val viewModel = createViewModel(DEFAULT_STATE)
@ -276,6 +373,26 @@ class AddSendViewModelTest : BaseViewModelTest() {
)
}
@Test
fun `ClearExpirationDate should clear the expiration date`() {
val initialState = DEFAULT_STATE.copy(
viewState = DEFAULT_VIEW_STATE.copy(
DEFAULT_COMMON_STATE.copy(
expirationDate = ZonedDateTime.parse("2024-09-13T00:00Z"),
),
),
)
val viewModel = createViewModel(initialState)
viewModel.trySendAction(AddSendAction.ClearExpirationDate)
assertEquals(
// DEFAULT expiration date is null
DEFAULT_STATE,
viewModel.stateFlow.value,
)
}
@Test
fun `ChooseFileClick should emit ShowToast`() = runTest {
val viewModel = createViewModel()
@ -450,7 +567,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
addSendType: AddSendType = AddSendType.AddItem,
): AddSendViewModel = AddSendViewModel(
savedStateHandle = SavedStateHandle().apply {
set("state", state)
set("state", state?.copy(addSendType = addSendType))
set(
"add_send_item_type",
when (addSendType) {
@ -469,11 +586,14 @@ class AddSendViewModelTest : BaseViewModelTest() {
companion object {
private const val ADD_SEND_STATE_EXTENSIONS_PATH: String =
"com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.AddSendStateExtensionsKt"
private const val ADD_SEND_VIEW_EXTENSIONS_PATH: String =
"com.x8bit.bitwarden.ui.tools.feature.send.addsend.util.SendViewExtensionsKt"
private const val SEND_VIEW_EXTENSIONS_PATH: String =
"com.x8bit.bitwarden.ui.tools.feature.send.util.SendViewExtensionsKt"
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "",
currentAccessCount = null,
maxAccessCount = null,
passwordInput = "",
noteInput = "",
@ -481,6 +601,7 @@ class AddSendViewModelTest : BaseViewModelTest() {
isDeactivateChecked = false,
deletionDate = ZonedDateTime.parse("2023-11-03T00:00Z"),
expirationDate = null,
sendUrl = null,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(

View file

@ -61,6 +61,7 @@ private val FIXED_CLOCK: Clock = Clock.fixed(
private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
name = "mockName-1",
currentAccessCount = 1,
maxAccessCount = 1,
passwordInput = "mockPassword-1",
noteInput = "mockNotes-1",
@ -68,6 +69,7 @@ private val DEFAULT_COMMON_STATE = AddSendState.ViewState.Content.Common(
isDeactivateChecked = false,
deletionDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
expirationDate = ZonedDateTime.parse("2023-10-27T12:00:00Z"),
sendUrl = null,
)
private val DEFAULT_SELECTED_TYPE_STATE = AddSendState.ViewState.Content.SendType.Text(

View file

@ -0,0 +1,78 @@
package com.x8bit.bitwarden.ui.tools.feature.send.addsend.util
import com.bitwarden.core.SendType
import com.x8bit.bitwarden.data.vault.datasource.sdk.model.createMockSendView
import com.x8bit.bitwarden.ui.tools.feature.send.addsend.AddSendState
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
class SendViewExtensionsTest {
@Test
fun `toViewState should create an appropriate ViewState for file type`() {
val sendView = createMockSendView(number = 1, type = SendType.FILE)
val result = sendView.toViewState(
clock = FIXED_CLOCK,
baseWebSendUrl = "www.test.com/",
)
assertEquals(DEFAULT_STATE, result)
}
@Test
fun `toViewState should create an appropriate ViewState for text type`() {
val sendView = createMockSendView(number = 1, type = SendType.TEXT)
val result = sendView.toViewState(
clock = FIXED_CLOCK,
baseWebSendUrl = "www.test.com/",
)
assertEquals(DEFAULT_STATE.copy(selectedType = DEFAULT_TEXT_TYPE), result)
}
}
private val FIXED_CLOCK: Clock = Clock.fixed(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
)
private val DEFAULT_COMMON: AddSendState.ViewState.Content.Common =
AddSendState.ViewState.Content.Common(
name = "mockName-1",
currentAccessCount = 1,
maxAccessCount = 1,
passwordInput = "",
noteInput = "mockNotes-1",
isHideEmailChecked = false,
isDeactivateChecked = false,
deletionDate = ZonedDateTime.ofInstant(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
),
expirationDate = ZonedDateTime.ofInstant(
Instant.parse("2023-10-27T12:00:00Z"),
ZoneOffset.UTC,
),
sendUrl = "www.test.com/mockAccessId-1/mockKey-1",
)
private val DEFAULT_TEXT_TYPE: AddSendState.ViewState.Content.SendType.Text =
AddSendState.ViewState.Content.SendType.Text(
input = "mockText-1",
isHideByDefaultChecked = false,
)
private val DEFAULT_FILE_TYPE: AddSendState.ViewState.Content.SendType.File =
AddSendState.ViewState.Content.SendType.File
private val DEFAULT_STATE: AddSendState.ViewState.Content =
AddSendState.ViewState.Content(
common = DEFAULT_COMMON,
selectedType = DEFAULT_FILE_TYPE,
)

View file

@ -18,6 +18,12 @@ import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performScrollToNode
import org.junit.jupiter.api.assertThrows
/**
* A [SemanticsMatcher] used to find editable text nodes.
*/
val isEditableText: SemanticsMatcher
get() = SemanticsMatcher.keyIsDefined(SemanticsProperties.EditableText)
/**
* A [SemanticsMatcher] used to find progressbar nodes.
*/