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

Fixes for forms, better docs for inputforms #7

Merged
merged 6 commits into from
May 30, 2023
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
1 change: 0 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ subprojects {
if (!executed) println("w: Disabling CInteropCommonization")
executed
}

}
register<org.gradle.jvm.tasks.Jar>("dokkaJavadocJar") {
// TODO: Dokka does not support javadocs for multiplatform dependencies
Expand Down
2 changes: 1 addition & 1 deletion buildSrc/src/main/kotlin/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ object Config {
const val majorRelease = 1
const val minorRelease = 1
const val patch = 0
const val versionName = "$majorRelease.$minorRelease.$patch-alpha01"
const val versionName = "$majorRelease.$minorRelease.$patch-alpha02"

// kotlin

Expand Down
139 changes: 89 additions & 50 deletions docs/inputforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,32 @@ The framework comes with a few basic classes you'll need:
- `ValidationError` - an error that happened during validation.
- `ValidationStrategy` - either `FailFast` or `LazyEval`, depending on how you want your form errors to be shown.

### Form
### 1. Understand how to use and create Rules

There are quite a few prebuilt Rules. See the `Rules` object to check them out.
Rules are a simple `fun interface`s that Specify how to validate a string.
Create your own rules like this:

```kotlin
fun LongerThan(minLength: Int): Rule = Rule { it: String ->
{ it.length >= minLength } checks { TooShort(it, minLength) }
}
```

A `Rule`'s `invoke` function takes a `String` and returns a `Sequence` of `ValidationError`s.
`infix fun (() -> Boolean).checks(error: ()-> ValidationError)` is a syntactic sugar for one-condition -> one error
validations. When the block returns `false`, an `error` is evaluated lazily and returned.

### 2. Choose a ValidationStrategy

There are 2 strategies: `FailFast` and `LazyEval`.

- `FailFast` means that as soon as an error is encountered, validation stops. A list of `ValidationError`s will only
contain 0 or 1 values. That's why the order of `Rule`s in the `Form` matters.
- `LazyEval` iterates through all the Rules, collects their `ValidationErrors`, and only then returns an appropriate
Input. Order of rules is preserved too.

### 3. Create your validation Form

A custom Form is built like this

Expand All @@ -39,33 +64,56 @@ val EmailForm = Form(
* The Form instance is usually located in the business logic layer.
* It can be a static object or a builder function return value.
* Some prebuilt forms are in the `Forms` object.
* If you want to add custom fields, you can subclass `Form` instead
```kotlin
object EmailForm : Form(
ValidationStrategy.FailFast,
/* ... */
) {
val LengthRange = 1..256 // can use on UI
}
```
### 4. Create Input values for string values you want to validate

### Input
For example, when building your state, use this to set up blank or default Inputs:

There are 3 things to do with Inputs:
```kotlin
val defaultName = "John Doe"

data class DisplayingSignInForm(
val email: Input = input(), // Input.Empty("")
val password: Input = input(),
val passwordConfirm: Input = input(),
val name: Input = input(defaultName), // Input.Valid("John Doe")
val isPasswordVisible: Boolean = false,
) : EmailSignInState
```

1. Start with a default value. For example, when building your state:
```kotlin
val defaultName = "John Doe"

data class DisplayingSignInForm(
val email: Input = input(), // Input.Empty("")
val password: Input = input(),
val passwordConfirm: Input = input(),
val name: Input = input(defaultName), // Input.Valid("John Doe")
val isPasswordVisible: Boolean = false,
) : EmailSignInState
```
2. Validate the input when the user changes it
```kotlin
val PasswordForm = Forms.Password()
fun onPasswordConfirmationChange(value: String) = _viewState.update {
it.copy(passwordConfirm = PasswordForm(value) mustMatch it.password)
}
```
- `Form.invoke(input: String)` returns a validated Input
- `Input.mustMatch(other: Input)` returns an input that is additionally required to be equal to another input
3. Display the value in the UI
### 5. Display `ValidationError`s

As simple as that, these are validation errors.
To add your own errors (when writing custom `Rule`s), subclass `ValidationError.Generic` and iterate over types.
To represent validation errors, you'll need a function that maps `List<ValidationError>` to a `String` or other
structure you want to use to display errors. Before you can draw your inputs, define that function.
This part of the implementation is always on you since it differs from app to app.
Simple example for Compose:

```kotlin
@Composable
fun List<ValidationError>.toRepresentation() = map { it.toRepresentation() }.joinToString("\n")

@Composable
private fun ValidationError.toRepresentation() = when (this) {
is ValidationError.Empty -> R.string.validation_error_empty
/* ... iterate over all types you want to support ... */
}.let(::stringResource)
```

### 6. Display the Input in the UI

Each validation will return either an `Input.Valid`, or `Input.Invalid` . An `Invalid` value contains a field
called `errors`, which is a collection of validation errors . Use that, the function you defined earlier, and the type
of the input type to display it. Example for Compose:

```kotlin
@Composable
Expand All @@ -75,48 +123,39 @@ fun InputTextField(input: Input, onTextChange: (String) -> Unit, modifier: Modif
value = input.value,
onValueChange = onTextChange,
isError = input is Input.Invalid,
// add max length display, available symbols, etc, coloring as needed.
)
// display errors below the text field
AnimatedVisibility(visible = input is Invalid) {
if (input !is Invalid) return@AnimatedVisibility
Text(
text = input.errors.toRepresentation(), // define a function that maps ValidationError -> String
text = input.errors.toRepresentation(),
modifier = Modifier.padding(2.dp),
)
}
}
}
```

### Rule
### 7. Validate the input when the user changes it

There are quite a few prebuilt Rules. See the `Rules` object to check them out.
Rules are a simple `fun interface`s that Specify how to validate a string.
Create your own rules like this:
Invoke a validation on the user's input (String value) each time the user changes it, or whenever you want to validate
forms. For example, like this:

```kotlin
fun LongerThan(minLength: Int): Rule = Rule { it: String ->
{ it.length >= minLength } checks { TooShort(it, minLength) }
val PasswordForm = Forms.Password()
fun onPasswordConfirmationChange(value: String) = _viewState.update {
it.copy(passwordConfirm = PasswordForm(value) mustMatch it.password)
}
```

A `Rule`'s `invoke` function takes a `String` and returns a `Sequence` of `ValidationError`s.
`infix fun (() -> Boolean).checks(error: ()-> ValidationError)` is a syntactic sugar for one-condition -> one error
validations. When the block returns `false`, an `error` is evaluated lazily and returned.

### ValidationStrategy
- `Form.invoke(input: String)` returns a validated Input
- `Input.mustMatch(other: Input)` returns an input that is additionally required to be equal to another input

There are 2 strategies: `FailFast` and `LazyEval`.
That's it.

- `FailFast` means that as soon as an error is encountered, validation stops. A list of `ValidationError`s will only
contain 0 or 1 values. That's why the order of `Rule`s in the `Form` matters.
- `LazyEval` iterates through all the Rules, collects their `ValidationErrors`, and only then returns an appropriate
Input. Order of rules is preserved too.

### ValidationError

As simple as that, these are validation errors.
To add your own errors (when writing custom `Rule`s), subclass `ValidationError.Generic` and iterate over types.
To represent validation errors, you'll need a function that maps `List<ValidationError>` to a `String` or other
structure you want to use to display errors.
That part of the implementation is always on you since it differs from app to app.
* If you are using mustMatch, be sure to validate **both** the dependant and the base fields whenever one
changes. Do this for each Input in your state.
* Validation can happen on a background thread, but be sure to update the state with the newest value
* **on the main thread and immediately**. Otherwise, you will face delays and jitter when trying to edit the text in the
form. So, copy the value of the input immediately, and validate it later
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import pro.respawn.kmmutils.inputforms.dsl.invoke
* @param strategy A [ValidationStrategy] to use
* @param rules A list of rules to use when validating. *Other of rules matters!*
*/
public class Form(
public open class Form(
public val strategy: ValidationStrategy,
protected vararg val rules: Rule,
) {
Expand All @@ -22,7 +22,7 @@ public class Form(
/**
* Run a validation using [rules].
*/
public fun validate(input: String): Input = rules(input, strategy).fold(input)
public open fun validate(input: String): Input = rules(input, strategy).fold(input)

public companion object
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public sealed interface Input {
*/
@JvmInline
public value class Empty internal constructor(
override val value: String = ""
override val value: String = "",
) : Input

/**
Expand All @@ -42,4 +42,15 @@ public sealed interface Input {
public value class Valid internal constructor(
override val value: String
) : Input

/**
* Copy the input. Type and errors are preserved
*/
public fun copy(value: String = this.value): Input = when (this) {
is Empty -> Empty(value)
is Invalid -> copy(value = value, errors = errors)
is Valid -> Valid(value)
}

public companion object
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,6 @@ public fun interface Rule {
* **If the validation passed, return an empty sequence**
*/
public operator fun invoke(value: String): Sequence<ValidationError>

public companion object
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,6 @@ public sealed interface ValidationError {
* @see pro.respawn.kmmutils.inputforms.default.Rules.Equals
*/
public data class IsNotEqual(override val value: String, val other: String) : ValidationError

public companion object
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ public sealed interface ValidationStrategy {
* the list of inputs may contain more than one error.
*/
public object LazyEval : ValidationStrategy

public companion object
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package pro.respawn.kmmutils.inputforms.dsl

import pro.respawn.kmmutils.common.isValid
import pro.respawn.kmmutils.common.takeIfValid
import pro.respawn.kmmutils.inputforms.Form
import pro.respawn.kmmutils.inputforms.Input
import pro.respawn.kmmutils.inputforms.Rule
Expand All @@ -13,7 +14,7 @@ import kotlin.jvm.JvmName
* @return [Input.Valid] if this is a non-empty string, or [Input.Empty] if the string is null or blank
* Use this when building an input for the first time to specify previous (pre-filled) values
*/
public fun input(value: String?): Input = if (value.isValid) Input.Valid(value!!) else Input.Empty(value ?: "")
public fun input(value: String? = null): Input = value.input()

/**
* @return [Input.Empty] with [default] -> [Input.value]
Expand All @@ -39,7 +40,7 @@ public fun String?.validate(rule: Rule): Input = (this ?: "").let { rule(it).fol
* value is a blank string.
*/
@JvmName("inputString")
public fun String?.input(): Input = input(this)
public fun String?.input(): Input = takeIfValid()?.let(Input::Valid) ?: Input.Empty("")

/**
* Create an [Input.Empty] from this string as a default.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public inline infix fun (() -> Boolean).checks(
* Runs all validations on a given sequence of rules.
* @return a list of [ValidationError]s resulted from a validation
*/
internal operator fun Sequence<Rule>.invoke(
public operator fun Sequence<Rule>.invoke(
input: String,
strategy: ValidationStrategy
): List<ValidationError> = when (strategy) {
Expand All @@ -45,21 +45,21 @@ internal operator fun Sequence<Rule>.invoke(
/**
* @see invoke
*/
internal operator fun Array<out Rule>.invoke(
public operator fun Array<out Rule>.invoke(
input: String,
strategy: ValidationStrategy,
): List<ValidationError> = asSequence()(input, strategy)

/**
* Fold [this] list of [ValidationError]s to an [Input] value. Use after running validation on a string.
*/
internal fun Iterable<ValidationError>.fold(value: String): Input = asSequence().fold(value)
public fun Iterable<ValidationError>.fold(value: String): Input = asSequence().fold(value)

/**
* Transform these [ValidationError]s into an [Input] value based on whether there are any errors
* If no errors, returns [Input.Valid] or [Input.Empty]
*/
internal fun Sequence<ValidationError>.fold(value: String): Input =
public fun Sequence<ValidationError>.fold(value: String): Input =
if (none()) input(value) else Input.Invalid(value, toList())

/**
Expand Down