Skip to content

Forms: Consume validationErrors stateflow #338

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

Merged
merged 5 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,4 @@ ignoreBuildNumber=false
# these versions define the dependency of the ArcGIS Maps SDK for Kotlin dependency
# and are generally not overridden at the command line unless a special build is requested.
sdkVersionNumber=200.4.0
sdkBuildNumber=4155
sdkBuildNumber=4157
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class MapViewModel @Inject constructor(
// build the list of errors
val errors = mutableListOf<ErrorInfo>()
val featureForm = state.featureForm
featureForm.getValidationErrors().forEach { entry ->
featureForm.validationErrors.value.forEach { entry ->
entry.value.forEach { error ->
featureForm.getFormElement(entry.key)?.let { formElement ->
if (formElement.isEditable.value) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
Expand Down Expand Up @@ -116,6 +117,12 @@ public fun FeatureForm(
}
}

@Composable
private fun FeatureFormTitle(featureForm: FeatureForm) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 thanks

val title by featureForm.title.collectAsState()
Text(text = title, style = TextStyle(fontWeight = FontWeight.Bold))
}

@Composable
private fun FeatureFormBody(
form: FeatureForm,
Expand All @@ -129,7 +136,7 @@ private fun FeatureFormBody(
horizontalAlignment = Alignment.CenterHorizontally
) {
// title
Text(text = form.title, style = TextStyle(fontWeight = FontWeight.Bold))
FeatureFormTitle(featureForm = form)
Spacer(
modifier = Modifier
.fillMaxWidth()
Expand Down Expand Up @@ -161,6 +168,10 @@ private fun FeatureFormBody(
.padding(horizontal = 15.dp, vertical = 10.dp)
)
}

else -> {
// other form elements are not created
}
}
}
}
Expand Down Expand Up @@ -236,6 +247,8 @@ internal fun rememberStates(
)
states.add(element, groupState)
}

else -> { }
}
}
return states
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,20 @@ package com.arcgismaps.toolkit.featureforms.components.base
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import com.arcgismaps.exceptions.FeatureFormValidationException
import com.arcgismaps.mapping.featureforms.FieldFormElement
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean

internal open class FieldProperties<T>(
val label: String,
val placeholder: String,
val description: String,
val value: StateFlow<T>,
val validationErrors: StateFlow<List<ValidationErrorState>>,
val required: StateFlow<Boolean>,
val editable: StateFlow<Boolean>,
val visible: StateFlow<Boolean>
Expand All @@ -50,24 +50,18 @@ internal data class Value<T>(
* Base state class for any Field within a feature form. It provides the default set of properties
* that are common to all [FieldFormElement]'s.
*
* Run [observeProperties] to start observing the [isEditable], [isRequired] and the [isFocused]
* flows. This also runs and generates any validation errors when any of those properties change.
*
* @param properties the [FieldProperties] associated with this state.
* @param initialValue optional initial value to set for this field. It is set to the value of
* [FieldProperties.value] by default.
* @param scope a [CoroutineScope] to start [StateFlow] collectors on.
* @param onEditValue a callback to invoke when the user edits result in a change of value. This
* is called on [BaseFieldState.onValueChanged].
* @param defaultValidator the default validator that returns the list of validation errors. This
* is called in [BaseFieldState.validate].
*/
internal abstract class BaseFieldState<T>(
properties: FieldProperties<T>,
initialValue: T = properties.value.value,
private val scope: CoroutineScope,
protected val onEditValue: (Any?) -> Unit,
protected val defaultValidator: () -> List<Throwable>
protected val onEditValue: (Any?) -> Unit
) : FormElementState(
label = properties.label,
description = properties.description,
Expand All @@ -86,14 +80,14 @@ internal abstract class BaseFieldState<T>(
/**
* Backing mutable state for the [value].
*/
private val _value : MutableState<Value<T>> = mutableStateOf(Value(initialValue))
private val _value: MutableState<Value<T>> = mutableStateOf(Value(initialValue))

/**
* Current value for this field state. The actual data of this type is wrapped in a [Value]
* object. The [Value.error] provides the current validation error for the [Value.data]. Use
* [onValueChanged] to set a value for this state.
*/
val value : State<Value<T>> = _value
val value: State<Value<T>> = _value

/**
* Property that indicates if the field is editable.
Expand All @@ -105,6 +99,11 @@ internal abstract class BaseFieldState<T>(
*/
val isRequired: StateFlow<Boolean> = properties.required

/**
* The validation errors for this field.
*/
val validationErrors: StateFlow<List<ValidationErrorState>> = properties.validationErrors

/**
* A mutable state flow to handle current focus state.
*/
Expand All @@ -121,25 +120,34 @@ internal abstract class BaseFieldState<T>(
*/
protected var wasFocused = false

/**
* Current status for [observeProperties].
*/
private var isObserving = AtomicBoolean(false)

init {
// start listening to calculated value updates immediately
scope.launch {
// update the current value state when the attribute value changes
_attributeValue.collect {
_value.value = Value(it)
// combine the attribute and validation errors flow so that we always have the latest
// value for both
combine(_attributeValue, validationErrors) { newValue, errors ->
Pair(newValue, errors)
}.collect {
// validate with the latest value and validation errors
updateValueWithValidation(it.first, it.second)
}
}
scope.launch {
// validate when focus changes
isFocused.collect {
updateValueWithValidation(_value.value.data, validationErrors.value)
}
}
scope.launch {
// validate when the editable property changes
isEditable.collect {
updateValueWithValidation(_value.value.data, validationErrors.value)
}
}
}

/**
* Callback to update the current value of the FormTextFieldState to the given [input]. This also
* sets the value on the feature using [onEditValue] and updates the validation using
* [updateValidation].
* Callback to update the current value of this state object to the given [input]. This also
* sets the value on the feature using [onEditValue].
*/
fun onValueChanged(input: T) {
// infer that a value change event comes from a user interaction and hence treat it as a
Expand All @@ -149,8 +157,6 @@ internal abstract class BaseFieldState<T>(
_value.value = Value(input)
// update the attributes
onEditValue(typeConverter(input))
// run validation
updateValidation(input)
}

/**
Expand All @@ -168,56 +174,18 @@ internal abstract class BaseFieldState<T>(
*/
fun forceValidation() {
wasFocused = true
updateValidation(_value.value.data)
updateValueWithValidation(_value.value.data, validationErrors.value)
}

/**
* Runs and updates the validation using [validate] and [filterErrors]. Avoid calling this
* method in any open/abstract class constructors since it directly invokes open members.
* Updates the [value] with a validation error if any, using [filterErrors].
*/
private fun updateValidation(value : T) {
val error = filterErrors(validate())
private fun updateValueWithValidation(value: T, errors: List<ValidationErrorState>) {
val error = filterErrors(errors)
// update the value with the validation error.
_value.value = Value(value, error)
}

/**
* Start observing the [isEditable], [isRequired] and [isFocused] flows and update
* the validation to generate any validation errors. This method must NOT be invoked from
* any initializer blocks of open/abstract classes since it indirectly invokes an open member
* using [updateValidation].
*/
protected fun observeProperties() {
// launch coroutines only once using an atomic boolean to check if they have already been
// launched
if (!isObserving.getAndSet(true)) {
scope.launch {
// validate when focus changes
isFocused.collect {
updateValidation(_value.value.data)
}
}
scope.launch {
// validate when required property changes
isRequired.collect {
updateValidation(_value.value.data)
}
}
scope.launch {
// validate when the editable property changes
isEditable.collect {
updateValidation(_value.value.data)
}
}
scope.launch {
// validate when the attribute value changes
_attributeValue.collect {
updateValidation(_value.value.data)
}
}
}
}

/**
* Filters a list of validation errors using the "field validation ui messaging algorithm"
* and returns a single validation error based on the current focus state, editable state
Expand Down Expand Up @@ -247,7 +215,7 @@ internal abstract class BaseFieldState<T>(
} else {
// show the first non-required error
errors.firstOrNull { it !is ValidationErrorState.Required }
// if none is found, do not show any error
// if none is found, do not show any error
?: ValidationErrorState.NoError
}
}
Expand All @@ -261,38 +229,12 @@ internal abstract class BaseFieldState<T>(
}
}

/**
* Validates the current value using the [defaultValidator].
*
* @return Returns the list of validation errors.
*/
open fun validate(): List<ValidationErrorState> {
val errors = defaultValidator()
return buildList {
errors.forEach {
when(it) {
is FeatureFormValidationException.RequiredException -> {
add(ValidationErrorState.Required)
}

is FeatureFormValidationException.OutOfDomainException -> {
add(ValidationErrorState.NotInCodedValueDomain)
}

is FeatureFormValidationException.NullNotAllowedException -> {
add(ValidationErrorState.NullNotAllowed)
}
}
}
}
}

/**
* Implement this method to provide the proper type conversion from [T] to an Any?. This
* method is used by [onValueChanged] to cast the [input] before calling
* [FieldFormElement.updateValue].
*
* @param input The value to convert
*/
abstract fun typeConverter(input: T) : Any?
abstract fun typeConverter(input: T): Any?
}
Loading