Skip to content

Commit

Permalink
Add functionality for invalidating field (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
boguszpawlowski authored Feb 12, 2022
1 parent eee716b commit b76e386
Show file tree
Hide file tree
Showing 8 changed files with 111 additions and 30 deletions.
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Chassis
A lightweight Kotlin library for a form state management and field validation.
A lightweight Kotlin library for form state management and field validation.

![Github Actions](https://github.com/boguszpawlowski/chassis/actions/workflows/check.yml/badge.svg)
[<img src="https://img.shields.io/maven-central/v/io.github.boguszpawlowski.chassis/chassis.svg?label=release%20version"/>](https://search.maven.org/search?q=g:io.github.boguszpawlowski.chassis)
Expand Down Expand Up @@ -55,6 +55,7 @@ The Chassis interface is a sort of manager for your form model. It consists of:
- `update` - a function for updating any field of the form, by passing an property reference (e.g. `LoginForm::login`) and the new value.
- `invoke` - a function for returning a current value of the form model - it can be used for getting the data after form submit, or just too peek the value (it's just a syntactic sugar for `Chassis.state.value` )
- `forceValidation` - a function for forcing the validation result of a field [Async validation](#async-validation).
- `invalidate` - a function for forcing validation on current value of the field [Validation on focus lost](#validation-on-focus-lost).
- `reset` - a function for resetting all fields to the initial values.

To create an instance of the `Chassis`, use the `chassis` builder function. It accepts value of the type you have provided as a representation of the form data.
Expand All @@ -69,10 +70,11 @@ To create an instance of the `Chassis`, use the `chassis` builder function. It a
### Field
The `Field` interface is the core component of the library. It consists of:
- `value` property - current value of the field.
- `isValid` - whenever the field is valid or not.
- `isInvalid` - whenever the field is invalid or not.
- `isValid` property - whenever the field is valid or not.
- `isInvalid` property - whenever the field is invalid or not.
- `invalidReasons` - list of all failed validation results.
- `invoke` function - it returns a current value of the field - it can be used for getting the data after form submit.
- `obtain` function - it returns a current value of the field - it can be used for getting the data after form submit.
- `invoke` function - a syntactic sugar for the `obtain` function
> :exclamation: 'invoke()' function calls a null-assertion (or cast for nullable fields), so you can only call it once you know the data is valid.
To create an instance of the `Field` use the `field` builder function. It accepts a lambda with the `FieldBuilderScope` receivers, which provides a `validators` and `reduce` functions.
Expand Down Expand Up @@ -245,6 +247,10 @@ The common scenario when dealing with forms is server side validation. For this
```
Such result will be appended to the list of the invalid reasons (if it's invalid) and it will be only present until the next update to the field's value.

### Validation on focus lost
Quite popular use-case is to validate the input when user moves to another field. To accomplish such behavior, you should use the `invalidate` function, when focus on
an field is lost. In the BasicSample file you can find an example of such behaviour, using `onFocusLost` modifier for Compose.

## License

Copyright 2022 Bogusz Pawłowski
Expand Down
4 changes: 3 additions & 1 deletion detekt-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ complexity:
LongMethod:
active: true
threshold: 60
ignoreAnnotated: ['Composable']
LongParameterList:
active: true
functionThreshold: 5
Expand Down Expand Up @@ -138,8 +139,9 @@ naming:
active: true
enumEntryPattern: '^[A-Z][a-z]+(?:[A-Z][a-z]+)*$'
FunctionNaming:
active: false
active: true
functionPattern: '^[a-z]+(?:[A-Z][a-z]+)*$'
ignoreAnnotated: [ 'Composable' ]
FunctionParameterNaming:
active: true
parameterPattern: '^[a-z]+(?:[A-Z][a-z]+)*$'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,17 @@ public interface Chassis<T : Any> {
* A function for forcing a validation results independently from declared validators (e.g. validation on server).
* Such validation result will be appended to the list of results from local validators and will be discarded on next update to the field.
*/
public fun <V : Any?> forceValidation(
public fun <V> forceValidation(
field: KProperty1<T, Field<T, V>>,
validationResult: ValidationResult,
)

/**
* A function for forcing validation on current value of the field. It can be used to force validation, when the input hasn't changed, for example
* when we loose focus on the text field.
*/
public fun <V> invalidate(field: KProperty1<T, Field<T, V>>)

/**
* A function for resetting all fields to the initial values.
*/
Expand All @@ -52,7 +58,7 @@ public interface Chassis<T : Any> {

@PublishedApi
internal class ChassisImpl<T : Any>(
private val initialValue: T
private val initialValue: T,
) : Chassis<T> {
private val _state = MutableStateFlow(initialValue)
override val state = _state
Expand All @@ -72,6 +78,11 @@ internal class ChassisImpl<T : Any>(
_state.value = newState
}

override fun <V> invalidate(field: KProperty1<T, Field<T, V>>) {
val newState = field.get(state.value).invalidate(state.value)
_state.value = newState
}

override fun reset() {
_state.value = initialValue
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,55 +7,66 @@ import io.github.boguszpawlowski.chassis.ValidationStrategy.AsOptional
* field itself. Note that you shouldn't use `update`, `forceValidation` functions on your own, as this are just a pure functions returning a new instance of the interface, and thus not updating the state.
* Instead, you should use it's counterparts on a `Chassis` interface.
*/
public interface Field<T : Any, V : Any?> {
public sealed class Field<T : Any, V> {
/**
* Current value of the field.
*/
public val value: V?
public abstract val value: V?

/**
* Whenever field is valid or not.
*/
public val isValid: Boolean
public abstract val isValid: Boolean

/**
* Whenever field is invalid or not.
* Syntactic sugar over `invalidReasons.isNotEmpty()`
*/
public val isInvalid: Boolean
public abstract val isInvalid: Boolean

/**
* List of reasons for failed validation. If you force the validation, the invalid cause of it will be appended to this list.
*/
public val invalidReasons: List<Invalid>
public abstract val invalidReasons: List<Invalid>

/**
* An effectively internal function for forcing the validation on the field. Use `forceValidation` of the chassis interface instead of it.
* A function for forcing the validation on the field. Use `forceValidation` of the chassis interface instead of it.
* @return a new instance of the field
* @param state current state of your model
* @param validationResult validation result which you want to force
*/
public fun forceValidation(state: T, validationResult: ValidationResult): T
internal abstract fun forceValidation(state: T, validationResult: ValidationResult): T

/**
* An effectively internal function for updating the fields value. Use `update` of the chassis interface instead of it.
* A function for updating the fields value. Use `update` of the chassis interface instead of it.
* @return new instance of the field
*/
public fun reduce(state: T, newValue: V?): T
internal abstract fun reduce(state: T, newValue: V?): T

/**
* A function for re-triggering the validation without a change to the input value
*/
internal abstract fun invalidate(state: T): T

/**
* A syntactic sugar for [resolve].
*/
public abstract operator fun invoke(): V

/**
* A function that will return current value casted to fields type. Use it only after the validation is completed.
*/
public operator fun invoke(): V
public abstract fun resolve(): V
}

internal data class FieldImpl<T : Any, V : Any?>(
override val value: V? = null,
private val validators: List<Validator<V>>,
private val validators: List<Validator<V?>>,
private val validationStrategy: ValidationStrategy,
private val reducer: Reducer<T, V>,
private val forcedValidation: List<ValidationResult> = emptyList()
) : Field<T, V> {
private val forcedValidation: List<ValidationResult> = emptyList(),
private val wasInvalidated: Boolean = false,
) : Field<T, V>() {

private val validationResults: List<ValidationResult>
get() = validate(value) + forcedValidation
Expand All @@ -75,15 +86,37 @@ internal data class FieldImpl<T : Any, V : Any?>(
}

override fun reduce(state: T, newValue: V?): T {
val newField = copy(value = newValue, forcedValidation = emptyList())
val newField = copy(
value = newValue,
forcedValidation = emptyList(),
wasInvalidated = false,
)
return reducer(state, newField)
}

override fun invoke(): V =
override fun invalidate(state: T): T {
val newField = copy(wasInvalidated = true)

return reducer(state, newField)
}

override fun invoke(): V = resolve()

@Suppress("UNCHECKED_CAST")
override fun resolve(): V =
if (validationStrategy == AsOptional) value as V else checkNotNull(value)

private fun validate(value: V?): List<ValidationResult> =
value?.let { nonNullValue ->
validators.map { it(nonNullValue) }
value.onNotNullOrInvalidated { notValidatedValue ->
validators.map { it(notValidatedValue) }
} ?: listOf(validationStrategy.fallback)

private inline fun V?.onNotNullOrInvalidated(
block: (V?) -> List<ValidationResult>,
): List<ValidationResult>? =
if (this != null || this@FieldImpl.wasInvalidated) {
block(this)
} else {
null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public typealias Reducer<T, V> = T.(Field<T, V>) -> T

@ChassisDslMarker
public interface FieldBuilderScope<T : Any, V : Any?> {
public fun validators(vararg validators: Validator<V>)
public fun validators(vararg validators: Validator<V?>)
}

public fun <T : Any, V : Any?> FieldBuilderScope<T, V>.reduce(reducer: Reducer<T, V>): Reducer<T, V> =
Expand All @@ -15,9 +15,9 @@ internal class FieldBuilder<T : Any, V : Any?>(
private val initialValue: V?,
private val validationStrategy: ValidationStrategy,
) : FieldBuilderScope<T, V> {
private val validators = arrayListOf<Validator<V>>()
private val validators = arrayListOf<Validator<V?>>()

override fun validators(vararg validators: Validator<V>) {
override fun validators(vararg validators: Validator<V?>) {
this.validators.addAll(validators)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ internal object Unspecified : ValidationResult
public object Valid : ValidationResult

/**
* A root interface for the validation failure class hierarchy. Implement it in order to return you custom failures from the validators.
* A root interface for the validation failure class hierarchy. Implement it in order to return your custom failures from the validators.
*/
public interface Invalid : ValidationResult

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,22 @@ internal class ChassisTest : ShouldSpec({
chassis.state.test {
awaitItem().asClue {
it.email.isInvalid shouldBe false
println(it.email.value)
println(it.email.isValid.toString())
it.login.isInvalid shouldBe false
it.password.isInvalid shouldBe false
it.marketingConsent.isInvalid shouldBe false
}
}
}
should("be invalid after invoking 'invalidate'") {
chassis.state.test {
awaitItem()
chassis.invalidate(LoginForm::login)
awaitItem().asClue {
it.login.isInvalid shouldBe true
it.login.isValid shouldBe false
}
}
}
should("throw exception on invocation") {
shouldThrow<IllegalStateException> {
chassis().email()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
Expand Down Expand Up @@ -49,6 +53,9 @@ fun MainScreen(viewModel: MainViewModel = MainViewModel()) {

Column {
TextField(
modifier = Modifier.onFocusLost {
viewModel.chassis.invalidate(LoginForm::email)
},
value = form.email.value.orEmpty(),
isError = form.email.isInvalid,
label = { Text(text = "Email") },
Expand Down Expand Up @@ -169,3 +176,17 @@ data class LoginForm(
@Suppress("MaxLineLength")
get() = login.isValid && email.isValid && password.isValid && marketingConsent.isValid && phoneNumber.isValid
}

fun Modifier.onFocusLost(onFocusLost: () -> Unit) = composed {
val (hadBeenFocused, onHadBeenFocusedChanged) = remember { mutableStateOf(false) }

this.then(
Modifier.onFocusChanged { focusState ->
val isFocused = focusState.isFocused
when {
isFocused && hadBeenFocused.not() -> onHadBeenFocusedChanged(true)
hadBeenFocused -> onFocusLost()
}
}
)
}

0 comments on commit b76e386

Please sign in to comment.