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

New Modifier for Validation Settings #4

Merged
merged 2 commits into from
Aug 23, 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
20 changes: 18 additions & 2 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,14 @@ with(emailValiator) {
value = value,
onValueChange = ::onValueChange,
modifier = Modifier
.validateFocusChanged() // will start validation on loss of focus
.shakeOnInvalid() // will shake the field when invalid and validate() is called
.validationConfig(
// will start validation on loss of focus
validateOnFocusLost = true,
// will shake the field when invalid and validate() is called
shakeOnInvalid = true,
// when false will delay [isError] until validate() is called
showErrorOnInteraction = false,
)
.fillMaxWidth(),
label = { Text("Email*") },
placeholder = { Text("Email") },
Expand All @@ -25,6 +31,16 @@ with(emailValiator) {
}
```

### Other Features

- Multiplatform support! Android, JVM, JS, Wasm, iOS!
- `TextFieldValueValidator` - a validator for `TextFieldValue` that can be used with `TextField` and `OutlinedTextField`
- `GenericValueValidator` - a generic validator that can be used to validate any type
- Can use with any Composable, not just `TextField`s!
- Build in `ValueValidatorRule`s to make putting forms together easier
- `Outcome` - Supports different levels of severity for validation messages, `Error`, `Warning`, `Info`, `Success`
- `ValidationConfig` - Allows you to configure how the validation should behave to user interaction

## Dependencies

By default we publish
Expand Down
138 changes: 117 additions & 21 deletions library/src/commonMain/kotlin/com/chrisjenx/yakcov/ValueValidator.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import kotlinx.coroutines.launch
abstract class ValueValidator<V, R>(
protected val state: MutableState<V>,
protected val rules: List<ValueValidatorRule<R>>,
protected val initialValidate: Boolean = false,
initialValidate: Boolean = false,
protected val alwaysShowRule: Boolean = false,
protected val validationSeparator: String = defaultValidationSeparator,
protected val shakeOnInvalid: Boolean = false,
Expand All @@ -32,10 +32,10 @@ abstract class ValueValidator<V, R>(
*/
var value: V by state

/**
* Set to true after first call of validate.
*/
private var shouldValidate: Boolean by mutableStateOf(initialValidate || rules.isEmpty())
internal var internalState: InternalState by mutableStateOf(
if (initialValidate || rules.isEmpty()) InternalState.Validating()
else InternalState.Initial
)

/**
* Current Field validation state, will be empty if no rules are set.
Expand All @@ -51,27 +51,34 @@ abstract class ValueValidator<V, R>(
* @see getValidationResultString
*/
val isValid: Boolean by derivedStateOf {
if (!shouldValidate) return@derivedStateOf false
if (internalState !is InternalState.Validating) return@derivedStateOf false
val severity = validationResults.map { it.outcome() }.maxOfOrNull { it.severity }
?: return@derivedStateOf false
severity < Outcome.ERROR.severity
}

/**
* Slightly different to [validate] this is prefered to be called whne the value changes and
* won't shake while the user is typing unlike [validate]
* Slightly different to [validate] this is preferred to be called when the value changes and
* won't shake while the user is typing unlike [validate]. This also will respect settings
* set by [validationConfig].
*
* @see validate
*/
open fun onValueChange(value: V?) {
validateWithResult(value, shake = false)
validateWithResult(value, shake = false, shouldShowError = showErrorOnUserInput)
}

/**
* Called when requesting if valid or to update the value and validate.
*
* This will shake if enabled, will also turn on errors if deferred by [validationConfig].
*
* @see validate
* @return true if the value if outcomes are all less than [Outcome.ERROR]
*/
open fun validate(value: V? = null): Boolean = validate(value, shake = true)
open fun validate(value: V? = null): Boolean {
return validate(value, shake = true, shouldShowError = true)
}

/**
* Called when requesting if valid or to update the value and validate.
Expand All @@ -80,15 +87,15 @@ abstract class ValueValidator<V, R>(
* @return the outcome of the validations, returns most severe outcome.
*/
open fun validateWithResult(value: V? = null): Outcome {
return validateWithResult(value, shake = true)
return validateWithResult(value, shake = true, shouldShowError = true)
}

/**
* @return true if the [ValueValidator] has started validation and any outcome is [Outcome.ERROR]
* or higher.
*/
fun isError(): Boolean = validationResults
.takeIf { shouldValidate }
.takeIf { (internalState as? InternalState.Validating)?.shouldShowError == true }
?.maxOfOrNull { it.outcome().severity >= Outcome.ERROR.severity }
?: false

Expand All @@ -99,7 +106,7 @@ abstract class ValueValidator<V, R>(
* @return the outcome of the validations, will be null if no validations have been run yet.
*/
fun outcome(): Outcome? = validationResults
.takeIf { shouldValidate }
.takeIf { internalState is InternalState.Validating }
?.maxByOrNull { it.outcome().severity }?.outcome()

/**
Expand All @@ -112,7 +119,7 @@ abstract class ValueValidator<V, R>(
)
fun getErrorString(): String? {
return validationResults
.takeIf { shouldValidate }
.takeIf { (internalState as? InternalState.Validating)?.shouldShowError == true }
?.filter { it.outcome().severity >= Outcome.ERROR.severity }
?.mapNotNull { it.format() }
?.takeIf { it.isNotEmpty() } // return null if no errors
Expand Down Expand Up @@ -143,15 +150,63 @@ abstract class ValueValidator<V, R>(
*/
@Composable
fun supportingText(severity: Short = Outcome.SUCCESS.severity): (@Composable () -> Unit)? {
if (alwaysShowRule || shouldValidate) getValidationResultString(severity)?.let { validations ->
val isValidating = internalState is InternalState.Validating
if (alwaysShowRule || isValidating) getValidationResultString(severity)?.let { validations ->
return { Text(validations) }
}
return null
}

/**
* Weather to show error message when user has started typing (or loosing focus),
* errors will then be show after [validate] is called.
* Default is true (matched the default value set on that method)
*
* @see validationConfig
*/
private var showErrorOnUserInput by mutableStateOf(true)

/**
* Returns a [Modifier] that will modify how the field acts to user interaction and validation
*
* @param validateOnFocusLost when the user leaves the field will start validation (and show
* error if invalid).
* @param shakeOnInvalid when [validate] is called AND the field is invalid it will
* shake the field to draw attention to the error.
* @param showErrorOnInteraction default `true`, will show error message [onValueChange]
* or if loosing focus when [validateOnFocusLost] is `true`.
* When you call [validate] it will start validating showing errors if present.
*/
@Composable
fun Modifier.validationConfig(
validateOnFocusLost: Boolean = false,
shakeOnInvalid: Boolean = false,
showErrorOnInteraction: Boolean = true,
): Modifier {
// Set scope if shaking is enabled
shakeOnInvalidScope = if (shakeOnInvalid) rememberCoroutineScope() else null
// Track if we should show error on user input
showErrorOnUserInput = showErrorOnInteraction
// Track focus
var hadFocus by mutableStateOf(false)
return this
.onFocusChanged { focusState ->
if (focusState.hasFocus) hadFocus = true
// Don't shake on loss of focus, as we want to just show the error
if (validateOnFocusLost && !focusState.isFocused && hadFocus) {
onValueChange(value = null)
}
}
.shakable(shakingState)
}

/**
* Returns a [Modifier] that will validate the [TextFieldValue] when the focus is lost.
*/
@Deprecated(
"Use validationConfig instead",
ReplaceWith("validationConfig(validateOnFocusLost = true)")
)
fun Modifier.validateFocusChanged(): Modifier {
var hadFocus by mutableStateOf(false)
return this.onFocusChanged { focusState ->
Expand All @@ -174,20 +229,32 @@ abstract class ValueValidator<V, R>(
* shake the field to draw attention to the error.
*/
@Composable
@Deprecated(
"Use validationConfig instead",
ReplaceWith("validationConfig(shakeOnInvalid = true)")
)
fun Modifier.shakeOnInvalid(): Modifier {
shakeOnInvalidScope = rememberCoroutineScope()
return this.shakable(shakingState)
}

// Internal validate method so focus and external validate act correctly
internal fun validate(value: V? = null, shake: Boolean = false): Boolean {
return validateWithResult(value, shake).severity < Outcome.ERROR.severity
internal fun validate(
value: V? = null,
shake: Boolean = false,
shouldShowError: Boolean? = null,
): Boolean {
return validateWithResult(value, shake, shouldShowError).severity < Outcome.ERROR.severity
}

// Internal validate method so focus and external validate act correctly
internal fun validateWithResult(value: V? = null, shake: Boolean = false): Outcome {
internal fun validateWithResult(
value: V? = null,
shake: Boolean = false,
shouldShowError: Boolean? = null,
): Outcome {
value?.also { this.value = it }
shouldValidate = true
internalState = internalState.toValidating(shouldShowError)
val outcome = validationResults
.map { it.outcome() }.maxByOrNull { it.severity } ?: Outcome.SUCCESS
if (shake && outcome.severity >= Outcome.ERROR.severity) {
Expand All @@ -206,13 +273,15 @@ abstract class ValueValidator<V, R>(

if (value != other.value) return false
if (rules != other.rules) return false
if (internalState != other.internalState) return false
return validationResults == other.validationResults
}

override fun hashCode(): Int {
var result = value.hashCode()
result = 31 * result + rules.hashCode()
result = 31 * result + validationResults.hashCode()
result = 31 * result + internalState.hashCode()
return result
}

Expand All @@ -227,6 +296,33 @@ abstract class ValueValidator<V, R>(
var defaultValidationSeparator = ", "
}

internal sealed class InternalState {
/**
* Initial state, no validation has been started yet.
*/
data object Initial : InternalState()

/**
* @param shouldShowError if true [isError] can be true, [supportingText]
* will still show messaging.
*/
data class Validating(val shouldShowError: Boolean = true) : InternalState()

/**
* Convert to validating state, if [canShowError] is null it will default to true or the
* current state if already in validating state.
*/
fun toValidating(canShowError: Boolean? = null): InternalState = when (this) {
is Initial -> Validating(shouldShowError = canShowError ?: true)
is Validating -> {
val canShow = canShowError ?: this.shouldShowError
// Once true we don't go back to false (unless we come from intial state)
this.copy(shouldShowError = this.shouldShowError || canShow)
}
}

}

}


Expand All @@ -237,7 +333,7 @@ abstract class ValueValidator<V, R>(
fun List<ValueValidator<*, *>>.validate(): Boolean {
// Fold to make sure we loop through all validators
return this.fold(true) { valid, validator ->
validator.validate(value = null, shake = true) && valid
validator.validate(value = null, shake = true, shouldShowError = true) && valid
}
}

Expand All @@ -246,7 +342,7 @@ fun List<ValueValidator<*, *>>.validate(): Boolean {
* the highest severity of all the validators passed in.
*/
fun List<ValueValidator<*, *>>.validateWithResult(): Outcome {
return this.map { it.validateWithResult(value = null, shake = true) }
return this.map { it.validateWithResult(value = null, shake = true, shouldShowError = true) }
.maxByOrNull { it.severity } ?: Outcome.SUCCESS
}

Loading