Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Custom Fields] Read-only mode for JSON values #12708

Merged
merged 14 commits into from
Oct 1, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,32 @@ data class CustomFieldUiModel(
val key: String,
val value: String,
val id: Long? = null,
val isJson: Boolean = false
) : Parcelable {
constructor(customField: CustomField) : this(customField.key, customField.valueAsString, customField.id)
constructor(customField: CustomField) : this(
key = customField.key,
value = customField.valueAsString,
id = customField.id,
isJson = customField.isJson
)

val valueStrippedHtml: String
get() = HtmlUtils.fastStripHtml(value)

@IgnoredOnParcel
val contentType: CustomFieldContentType = CustomFieldContentType.fromMetadataValue(value)

fun toDomainModel() = CustomField(
id = id ?: 0, // Use 0 for new custom fields
key = key,
value = WCMetaDataValue.StringValue(value) // Treat all updates as string values
)
fun toDomainModel(): CustomField {
require(!isJson) {
"Editing JSON custom fields is not supported, this shouldn't be called for JSON custom fields"
}

return CustomField(
id = id ?: 0, // Use 0 for new custom fields
key = key,
value = WCMetaDataValue.StringValue(value) // Treat all updates as string values
)
}
}

enum class CustomFieldContentType {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.woocommerce.android.ui.customfields.list
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
Expand All @@ -20,9 +21,11 @@ import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FloatingActionButton
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.SnackbarHost
import androidx.compose.material.SnackbarHostState
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
Expand All @@ -46,17 +49,21 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.woocommerce.android.R
import com.woocommerce.android.ui.compose.component.DiscardChangesDialog
import com.woocommerce.android.ui.compose.component.ExpandableTopBanner
import com.woocommerce.android.ui.compose.component.ProgressDialog
import com.woocommerce.android.ui.compose.component.Toolbar
import com.woocommerce.android.ui.compose.component.WCColoredButton
import com.woocommerce.android.ui.compose.component.WCTextButton
import com.woocommerce.android.ui.compose.preview.LightDarkThemePreviews
import com.woocommerce.android.ui.compose.theme.WooThemeWithBackground
import com.woocommerce.android.ui.customfields.CustomField
import com.woocommerce.android.ui.customfields.CustomFieldContentType
import com.woocommerce.android.ui.customfields.CustomFieldUiModel
import org.json.JSONArray
import org.json.JSONObject

@Composable
fun CustomFieldsScreen(
Expand All @@ -75,6 +82,13 @@ fun CustomFieldsScreen(
snackbarHostState = snackbarHostState
)
}

viewModel.overlayedField.observeAsState().value?.let { overlayedField ->
JsonCustomFieldViewer(
customField = overlayedField,
onDismiss = viewModel::onOverlayedFieldDismissed
)
}
}

@OptIn(ExperimentalMaterialApi::class)
Expand Down Expand Up @@ -243,6 +257,56 @@ private fun CustomFieldItem(
}
}

@Composable
private fun JsonCustomFieldViewer(
customField: CustomFieldUiModel,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.medium,
) {
val jsonFormatted = remember(customField.value) {
runCatching {
if (customField.value.trimStart().startsWith("[")) {
JSONArray(customField.value).toString(4)
} else {
JSONObject(customField.value).toString(4)
}
}.getOrDefault(customField.value)
}

Comment on lines +283 to +292
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't add this formatting logic to the ViewModel because it's UI only, and uses an Android specific class org.json.*, let me know if you have some concerns with this approach.

Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(16.dp)
) {
OutlinedTextField(
value = customField.key,
onValueChange = {},
label = { Text(text = stringResource(id = R.string.custom_fields_editor_key_label)) },
readOnly = true,
modifier = Modifier
)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used read-only text fields for displaying the custom field's content because it has built-in support for selection and allowing copy/paste.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call! Just a minor nitpick. From a UX point of view, having the edit text fields focusable (highlighted with the purple border) when they are not editable seems confusing.
I was wondering if we should use enabled = false instead of readOnly = true. This still allows for copy pasting (although usability is worse for selecting the text), but makes it way more apparent that the content is not editable.
Wdyt?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point here @JorgeMucientes, I made some updates to disable the focus of the fields, I didn't use enabled = false reduced the experience a lot, please check the commit dbcf0be and let me know what you think.

With this commit, and the other UI updates suggested below, the result looks like this now:

Screen_recording_20241001_130945.mp4


OutlinedTextField(
value = jsonFormatted,
onValueChange = {},
label = { Text(text = stringResource(id = R.string.custom_fields_editor_value_label)) },
readOnly = true,
modifier = Modifier.weight(1f, fill = false)
)

WCColoredButton(
onClick = onDismiss,
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
Text(text = stringResource(id = R.string.close))
}
}
}
}
}

@LightDarkThemePreviews
@Preview
@Composable
Expand Down Expand Up @@ -274,3 +338,21 @@ private fun CustomFieldsScreenPreview() {
)
}
}

@LightDarkThemePreviews
@Preview
@Composable
private fun JsonCustomFieldViewerPreview() {
WooThemeWithBackground {
JsonCustomFieldViewer(
customField = CustomFieldUiModel(
CustomField(
id = 0,
key = "key1",
value = "[{\"key\": \"value\"}]"
)
),
onDismiss = {}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ import androidx.lifecycle.asLiveData
import androidx.lifecycle.viewModelScope
import com.woocommerce.android.AppPrefsWrapper
import com.woocommerce.android.R
import com.woocommerce.android.extensions.combine
import com.woocommerce.android.ui.customfields.CustomFieldUiModel
import com.woocommerce.android.ui.customfields.CustomFieldsRepository
import com.woocommerce.android.viewmodel.MultiLiveEvent
import com.woocommerce.android.viewmodel.ResourceProvider
import com.woocommerce.android.viewmodel.ScopedViewModel
import com.woocommerce.android.viewmodel.getNullableStateFlow
import com.woocommerce.android.viewmodel.getStateFlow
import com.woocommerce.android.viewmodel.navArgs
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
Expand All @@ -37,28 +40,43 @@ class CustomFieldsViewModel @Inject constructor(

private val isRefreshing = MutableStateFlow(false)
private val isSaving = MutableStateFlow(false)
private val customFields = repository.observeDisplayableCustomFields(args.parentItemId)
.shareIn(viewModelScope, started = SharingStarted.Lazily)

private val showDiscardChangesDialog = savedStateHandle.getStateFlow(
scope = viewModelScope,
initialValue = false,
key = "showDiscardChangesDialog"
)
private val customFields = repository.observeDisplayableCustomFields(args.parentItemId)
private val pendingChanges = savedStateHandle.getStateFlow(viewModelScope, PendingChanges())
private val overlayedFieldId = savedStateHandle.getNullableStateFlow(
scope = viewModelScope,
initialValue = null,
clazz = Long::class.java,
key = "overlayedFieldId"
)

private val bannerDismissed = appPrefs.observePrefs()
.onStart { emit(Unit) }
.map { appPrefs.isCustomFieldsTopBannerDismissed }
.distinctUntilChanged()

val state = combine(
private val customFieldsWithChanges = combine(
customFields,
pendingChanges,
pendingChanges
) { customFields, pendingChanges ->
Pair(customFields.map { CustomFieldUiModel(it) }.combineWithChanges(pendingChanges), pendingChanges)
}

val state = combine(
customFieldsWithChanges,
isRefreshing,
isSaving,
showDiscardChangesDialog,
bannerDismissed
) { customFields, pendingChanges, isLoading, isSaving, isShowingDiscardDialog, bannerDismissed ->
) { (customFields, pendingChanges), isLoading, isSaving, isShowingDiscardDialog, bannerDismissed ->
UiState(
customFields = customFields.map { CustomFieldUiModel(it) }.combineWithChanges(pendingChanges),
customFields = customFields,
isRefreshing = isLoading,
isSaving = isSaving,
hasChanges = pendingChanges.hasChanges,
Expand All @@ -76,6 +94,13 @@ class CustomFieldsViewModel @Inject constructor(
)
}.asLiveData()

val overlayedField = combine(
customFieldsWithChanges,
overlayedFieldId
) { (customFields, _), fieldId ->
fieldId?.let { customFields.find { it.id == fieldId } }
}.asLiveData()

fun onBackClick() {
if (pendingChanges.value.hasChanges) {
showDiscardChangesDialog.value = true
Expand All @@ -95,7 +120,15 @@ class CustomFieldsViewModel @Inject constructor(
}

fun onCustomFieldClicked(field: CustomFieldUiModel) {
triggerEvent(OpenCustomFieldEditor(field))
if (field.isJson) {
overlayedFieldId.value = field.id
} else {
triggerEvent(OpenCustomFieldEditor(field))
}
}

fun onOverlayedFieldDismissed() {
overlayedFieldId.value = null
}

fun onCustomFieldValueClicked(field: CustomFieldUiModel) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ fun <T : Any> SavedStateHandle.getStateFlow(
initialValue: T,
key: String = initialValue.javaClass.name
): MutableStateFlow<T> {
if (initialValue !is Parcelable && initialValue !is Serializable) {
error("getStateFlow supports only types that are either Parcelable or Serializable")
if (initialValue !is Parcelable && initialValue !is Serializable && !initialValue.javaClass.isPrimitive) {
error("getStateFlow supports only types that are either Parcelable or Serializable or primitives")
}

return getStateFlowInternal(scope, initialValue, key)
Expand All @@ -41,9 +41,10 @@ fun <T : Any?> SavedStateHandle.getNullableStateFlow(
key: String = clazz.name
): MutableStateFlow<T> {
if (!Parcelable::class.java.isAssignableFrom(clazz) &&
!Serializable::class.java.isAssignableFrom(clazz)
!Serializable::class.java.isAssignableFrom(clazz) &&
!clazz.isPrimitive
) {
error("getStateFlow supports only types that are either Parcelable or Serializable")
error("getStateFlow supports only types that are either Parcelable or Serializable or primitives")
}

return getStateFlowInternal(scope, initialValue, key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.wordpress.android.fluxc.model.metadata.MetaDataParentItemType
import org.wordpress.android.fluxc.model.metadata.WCMetaDataValue

@OptIn(ExperimentalCoroutinesApi::class)
class CustomFieldsViewModelTest : BaseUnitTest() {
Expand Down Expand Up @@ -396,24 +397,26 @@ class CustomFieldsViewModelTest : BaseUnitTest() {
}

@Test
fun `given custom fields top banner is dismissed, when screen is opened, then banner is not shown`() = testBlocking {
appPrefs.isCustomFieldsTopBannerDismissed = true
setup()
fun `given custom fields top banner is dismissed, when screen is opened, then banner is not shown`() =
testBlocking {
appPrefs.isCustomFieldsTopBannerDismissed = true
setup()

val state = viewModel.state.getOrAwaitValue()
val state = viewModel.state.getOrAwaitValue()

assertThat(state.topBannerState).isNull()
}
assertThat(state.topBannerState).isNull()
}

@Test
fun `given custom fields top banner is not dismissed, when screen is opened, then banner is shown`() = testBlocking {
appPrefs.isCustomFieldsTopBannerDismissed = false
setup()
fun `given custom fields top banner is not dismissed, when screen is opened, then banner is shown`() =
testBlocking {
appPrefs.isCustomFieldsTopBannerDismissed = false
setup()

val state = viewModel.state.getOrAwaitValue()
val state = viewModel.state.getOrAwaitValue()

assertThat(state.topBannerState).isNotNull
}
assertThat(state.topBannerState).isNotNull
}

@Test
fun `given custom fields top banner is shown, when banner is dismissed, then banner is not shown`() = testBlocking {
Expand All @@ -427,4 +430,43 @@ class CustomFieldsViewModelTest : BaseUnitTest() {

assertThat(state.topBannerState).isNull()
}

@Test
fun `when a json custom field is clicked, then show it in an overlay`() = testBlocking {
val customField = CustomField(
id = 1,
key = "key",
value = WCMetaDataValue.fromRawString("{\"key\": \"value\"}")
)
setup {
whenever(repository.observeDisplayableCustomFields(PARENT_ITEM_ID)).thenReturn(flowOf(listOf(customField)))
}

val uiModel = CustomFieldUiModel(customField)
val overlayedField = viewModel.overlayedField.runAndCaptureValues {
viewModel.onCustomFieldClicked(uiModel)
}.last()

assertThat(overlayedField).isEqualTo(uiModel)
}

@Test
fun `when overlayed field is dismissed, then overlay is removed`() = testBlocking {
val customField = CustomField(
id = 1,
key = "key",
value = WCMetaDataValue.fromRawString("{\"key\": \"value\"}")
)
setup {
whenever(repository.observeDisplayableCustomFields(PARENT_ITEM_ID)).thenReturn(flowOf(listOf(customField)))
}

val uiModel = CustomFieldUiModel(customField)
val overlayedField = viewModel.overlayedField.runAndCaptureValues {
viewModel.onCustomFieldClicked(uiModel)
viewModel.onOverlayedFieldDismissed()
}.last()

assertThat(overlayedField).isNull()
}
}
Loading
Loading