Skip to content

Commit

Permalink
Merge pull request #12708 from woocommerce/issue/12699-json-fields-re…
Browse files Browse the repository at this point in the history
…ad-only

[Custom Fields] Read-only mode for JSON values
  • Loading branch information
hichamboushaba authored Oct 1, 2024
2 parents 392343e + 8452fc7 commit 14b24c0
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 30 deletions.
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,10 @@ 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.focusable
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
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 +24,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,6 +52,7 @@ 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
Expand All @@ -57,6 +64,10 @@ 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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import org.json.JSONArray
import org.json.JSONObject

@Composable
fun CustomFieldsScreen(
Expand All @@ -75,6 +86,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 +261,68 @@ private fun CustomFieldItem(
}
}

@Composable
private fun JsonCustomFieldViewer(
customField: CustomFieldUiModel,
onDismiss: () -> Unit
) {
// We use this to disable focus on the text fields used to show the key and value as it's not needed for our case
val inactiveInteractionSource = remember {
object : MutableInteractionSource {
override val interactions: Flow<Interaction> = emptyFlow()
override suspend fun emit(interaction: Interaction) = Unit
override fun tryEmit(interaction: Interaction): Boolean = false
}
}

Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.medium,
modifier = Modifier.padding(vertical = 16.dp)
) {
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)
}

Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)
) {
OutlinedTextField(
value = customField.key,
onValueChange = {},
label = { Text(text = stringResource(id = R.string.custom_fields_editor_key_label)) },
readOnly = true,
interactionSource = inactiveInteractionSource,
modifier = Modifier.focusable(enabled = false)
)

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

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

@LightDarkThemePreviews
@Preview
@Composable
Expand Down Expand Up @@ -274,3 +354,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
Loading

0 comments on commit 14b24c0

Please sign in to comment.