From e3ca24b804cb4537b17b3a0123cac67890d7da3d Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Fri, 17 Apr 2026 17:18:24 +0200 Subject: [PATCH 1/6] Introduce schema --- README.md | 26 ++ .../kotlin/dev/voir/formica/ValidationRule.kt | 305 --------------- .../dev/voir/formica/schema/FieldBuilder.kt | 223 +++++++++++ .../dev/voir/formica/schema/FieldError.kt | 6 + .../dev/voir/formica/schema/FieldSchema.kt | 26 ++ .../formica/schema/FieldValidationResult.kt | 7 + .../kotlin/dev/voir/formica/schema/Schema.kt | 28 ++ .../dev/voir/formica/schema/SchemaBuilder.kt | 26 ++ .../formica/schema/ValidationException.kt | 7 + .../voir/formica/schema/ValidationResult.kt | 13 + .../dev/voir/formica/schema/ValidationRule.kt | 5 + .../voir/formica/schema/ValidationRules.kt | 350 ++++++++++++++++++ 12 files changed, 717 insertions(+), 305 deletions(-) delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt diff --git a/README.md b/README.md index af20bd3..809a40f 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,32 @@ maven { } ``` +```kotlin +val UserSchema = schema { + field(User::email) { + required("Email is required") + email() + } + + field(User::password) { + strongPassword() + } + + field(User::age) { + min(18) + max(99) + } + + field(User::website) { + url(protocolRequired = true) + } + + field(User::termsAccepted) { + checked() + } +} +``` + ## Getting started 1. Define your form data schema diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt deleted file mode 100644 index ee9301c..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ValidationRule.kt +++ /dev/null @@ -1,305 +0,0 @@ -package dev.voir.formica - -fun interface ValidationRule { - fun validate(value: V): FormicaFieldResult -} - -object ValidationRules { - fun validateOnlyIf(active: () -> Boolean, rule: ValidationRule) = - ValidationRule { v -> if (active()) rule.validate(v) else FormicaFieldResult.Success } - - fun required( - message: String = "Field is required", - isEmpty: (V?) -> Boolean = { v -> - when (v) { - null -> true - is String -> v.isBlank() - is Collection<*> -> v.isEmpty() - else -> false - } - } - ): ValidationRule = ValidationRule { v -> - if (isEmpty(v)) { - FormicaFieldResult.Error(message) - } else { - FormicaFieldResult.Success - } - } - - /* TODO Maybe useful - fun requiredIf( - form: Formica, - predicate: (D) -> Boolean, - message: String - ): ValidationRule = ValidationRule { v -> - val active = predicate(form.data.value) // read live snapshot - if (!active) return@ValidationRule FormicaFieldResult.Success - - val empty = when (v) { - null -> true - is String -> v.isBlank() - is Collection<*> -> v.isEmpty() - else -> false - } - if (empty) FormicaFieldResult.Error(message) else FormicaFieldResult.Success - }*/ - - - fun notEmpty( - message: String = "This field cannot be empty." - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FormicaFieldResult.NoInput - v.isNotEmpty() -> FormicaFieldResult.Success - else -> FormicaFieldResult.Error(message) - } - } - - fun notBlank( - message: String = "This field cannot be blank." - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FormicaFieldResult.NoInput - v.isNotBlank() -> FormicaFieldResult.Success - else -> FormicaFieldResult.Error(message) - } - } - - fun email( - message: String = "Must be a valid email address.", - pattern: Regex = EMAIL_PATTERN.toRegex() - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FormicaFieldResult.NoInput - v.matches(pattern) -> FormicaFieldResult.Success - else -> FormicaFieldResult.Error(message) - } - } - - fun strongPassword( - minLength: Int = 8, - lengthMessage: String = "Password must be at least $minLength characters long.", - uppercaseMessage: String = "Password must contain at least one uppercase letter.", - lowercaseMessage: String = "Password must contain at least one lowercase letter.", - digitMessage: String = "Password must contain at least one digit.", - specialCharacterMessage: String = "Password must contain at least one special character.", - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FormicaFieldResult.NoInput - v.length < minLength -> FormicaFieldResult.Error(lengthMessage) - !v.any { it.isUpperCase() } -> FormicaFieldResult.Error(uppercaseMessage) - !v.any { it.isLowerCase() } -> FormicaFieldResult.Error(lowercaseMessage) - !v.any { it.isDigit() } -> FormicaFieldResult.Error(digitMessage) - !v.any { it in "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" } -> FormicaFieldResult.Error( - specialCharacterMessage - ) - - else -> FormicaFieldResult.Success - } - } - - fun url( - protocolRequired: Boolean = false, - message: String = "Must be a valid URL." - ): ValidationRule = ValidationRule { v -> - if (v == null) { - return@ValidationRule FormicaFieldResult.NoInput - } - - val result = if (protocolRequired) { - v.matches(HTTP_URL_PATTERN.toRegex()) - } else { - v.matches(HTTP_URL_PATTERN.toRegex()) || v.matches(DOMAIN_URL_PATTERN.toRegex()) - } - - if (result) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message) - } - } - - fun checked(message: String = "Must be checked"): ValidationRule = - ValidationRule { v -> - if (v == null) { - return@ValidationRule FormicaFieldResult.NoInput - } - - if (v) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message) - } - } - - fun minLength( - option: Int, - message: String = "Must be at least $option characters long.", - ): ValidationRule = ValidationRule { v -> - if (v == null) { - return@ValidationRule FormicaFieldResult.NoInput - } - if (v.count() >= option) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message) - } - } - - fun maxLength( - option: Int, - message: String = "Must not exceed $option characters.", - ): ValidationRule = ValidationRule { v -> - if (v == null) { - return@ValidationRule FormicaFieldResult.NoInput - } - if (v.count() <= option) { - FormicaFieldResult.Success - } else { - FormicaFieldResult.Error(message) - } - } - - fun range( - min: T, - max: T, - inclusive: Boolean = true, - message: (min: T, max: T) -> String = { lo, hi -> "Must be a number between $lo and $hi." } - ): ValidationRule where T : Number, T : Comparable = ValidationRule { v -> - if (v == null) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v >= min && v <= max else v > min && v < max - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(min, max)) - } - - fun range( - min: Double, - max: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double, Double) -> String = { lo, hi -> "Must be a number between $lo and $hi." } - ): ValidationRule = ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FormicaFieldResult.NoInput - val lower = if (inclusive) v >= min - epsilon else v > min + epsilon - val upper = if (inclusive) v <= max + epsilon else v < max - epsilon - if (lower && upper) FormicaFieldResult.Success else FormicaFieldResult.Error( - message( - min, - max - ) - ) - } - - fun range( - min: Float, - max: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float, Float) -> String = { lo, hi -> "Must be a number between $lo and $hi." } - ): ValidationRule = ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FormicaFieldResult.NoInput - val lower = if (inclusive) v >= min - epsilon else v > min + epsilon - val upper = if (inclusive) v <= max + epsilon else v < max - epsilon - if (lower && upper) FormicaFieldResult.Success else FormicaFieldResult.Error( - message( - min, - max - ) - ) - } - - fun min( - min: Int, - inclusive: Boolean = true, - message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v >= min else v > min - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(min)) - } - - fun max( - max: Int, - inclusive: Boolean = true, - message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v <= max else v < max - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(max)) - } - - fun min( - min: Long, - inclusive: Boolean = true, - message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v >= min else v > min - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(min)) - } - - fun max( - max: Long, - inclusive: Boolean = true, - message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v <= max else v < max - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(max)) - } - - fun min( - min: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v >= min - epsilon else v > min + epsilon - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(min)) - } - - fun max( - max: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v <= max + epsilon else v < max - epsilon - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(max)) - } - - fun min( - min: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v >= min - epsilon else v > min + epsilon - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(min)) - } - - fun max( - max: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FormicaFieldResult.NoInput - val ok = if (inclusive) v <= max + epsilon else v < max - epsilon - if (ok) FormicaFieldResult.Success else FormicaFieldResult.Error(message(max)) - } -} - -private const val EMAIL_PATTERN = "[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + - "(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+" - -private const val DOMAIN_URL_PATTERN = - "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" -private const val HTTP_URL_PATTERN = - "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt new file mode 100644 index 0000000..5647e04 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt @@ -0,0 +1,223 @@ +package dev.voir.formica.schema + +class FieldBuilder { + private val rules = mutableListOf>() + + fun rule(rule: ValidationRule) { + rules += rule + } + + internal fun build(): List> = rules +} + +fun FieldBuilder.required( + message: String = "Field is required", + isEmpty: (V?) -> Boolean = { v -> + when (v) { + null -> true + is String -> v.isBlank() + is Collection<*> -> v.isEmpty() + else -> false + } + } +) { + rule(ValidationRules.required(message, isEmpty)) +} + +fun FieldBuilder.validateOnlyIf( + active: () -> Boolean, + rule: ValidationRule +) { + rule(ValidationRules.validateOnlyIf(active, rule)) +} + +fun FieldBuilder.notEmpty( + message: String = "This field cannot be empty." +) { + rule(ValidationRules.notEmpty(message)) +} + +fun FieldBuilder.notBlank( + message: String = "This field cannot be blank." +) { + rule(ValidationRules.notBlank(message)) +} + +fun FieldBuilder.email( + message: String = "Must be a valid email address.", + pattern: Regex = EMAIL_PATTERN.toRegex() +) { + rule(ValidationRules.email(message, pattern)) +} + +fun FieldBuilder.strongPassword( + minLength: Int = 8, + lengthMessage: String = "Password must be at least $minLength characters long.", + uppercaseMessage: String = "Password must contain at least one uppercase letter.", + lowercaseMessage: String = "Password must contain at least one lowercase letter.", + digitMessage: String = "Password must contain at least one digit.", + specialCharacterMessage: String = "Password must contain at least one special character.", +) { + rule( + ValidationRules.strongPassword( + minLength, + lengthMessage, + uppercaseMessage, + lowercaseMessage, + digitMessage, + specialCharacterMessage + ) + ) +} + +fun FieldBuilder.url( + protocolRequired: Boolean = false, + message: String = "Must be a valid URL." +) { + rule(ValidationRules.url(protocolRequired, message)) +} + +fun FieldBuilder.minLength( + option: Int, + message: String = "Must be at least $option characters long." +) { + rule(ValidationRules.minLength(option, message)) +} + +fun FieldBuilder.maxLength( + option: Int, + message: String = "Must not exceed $option characters." +) { + rule(ValidationRules.maxLength(option, message)) +} + +fun FieldBuilder.notEmpty( + message: String = "This field cannot be empty." +) { + rule( + ValidationRule { v -> + if (v.isNotEmpty()) FieldValidationResult.Success + else FieldValidationResult.Error(message) + } + ) +} + +fun FieldBuilder.notBlank( + message: String = "This field cannot be blank." +) { + rule( + ValidationRule { v -> + if (v.isNotBlank()) FieldValidationResult.Success + else FieldValidationResult.Error(message) + } + ) +} + +fun FieldBuilder.email( + message: String = "Must be a valid email address.", + pattern: Regex = EMAIL_PATTERN.toRegex() +) { + rule( + ValidationRule { v -> + if (pattern.matches(v)) FieldValidationResult.Success + else FieldValidationResult.Error(message) + } + ) +} + +fun FieldBuilder.range( + min: N, + max: N, + inclusive: Boolean = true, + message: (N, N) -> String = { lo, hi -> "Must be a number between $lo and $hi." } +) where N : Number, N : Comparable { + rule(ValidationRules.range(min, max, inclusive, message)) +} + +fun FieldBuilder.range( + min: Double, + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double, Double) -> String = { lo, hi -> "Must be a number between $lo and $hi." } +) { + rule(ValidationRules.range(min, max, inclusive, epsilon, message)) +} + +fun FieldBuilder.range( + min: Float, + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float, Float) -> String = { lo, hi -> "Must be a number between $lo and $hi." } +) { + rule(ValidationRules.range(min, max, inclusive, epsilon, message)) +} + +fun FieldBuilder.min( + min: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, message)) +} + +fun FieldBuilder.max( + max: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, message)) +} + +fun FieldBuilder.min( + min: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, message)) +} + +fun FieldBuilder.max( + max: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, message)) +} + +fun FieldBuilder.min( + min: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, epsilon, message)) +} + +fun FieldBuilder.max( + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, epsilon, message)) +} + +fun FieldBuilder.min( + min: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, epsilon, message)) +} + +fun FieldBuilder.max( + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, epsilon, message)) +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt new file mode 100644 index 0000000..75184de --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt @@ -0,0 +1,6 @@ +package dev.voir.formica.schema + +data class FieldError( + val field: String, + val message: String +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt new file mode 100644 index 0000000..ad1c574 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt @@ -0,0 +1,26 @@ +package dev.voir.formica.schema + +import kotlin.reflect.KProperty1 + +class FieldSchema( + val property: KProperty1, + private val rules: List> +) { + val name: String get() = property.name + + fun validate(instance: T): List { + val value = property.get(instance) + + return buildList { + for (rule in rules) { + when (val result = rule.validate(value)) { + FieldValidationResult.Success, + FieldValidationResult.Skip -> Unit + + is FieldValidationResult.Error -> + add(FieldError(name, result.message)) + } + } + } + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt new file mode 100644 index 0000000..5db84ac --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt @@ -0,0 +1,7 @@ +package dev.voir.formica.schema + +sealed interface FieldValidationResult { + data object Success : FieldValidationResult + data object Skip : FieldValidationResult + data class Error(val message: String) : FieldValidationResult +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt new file mode 100644 index 0000000..b570d72 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt @@ -0,0 +1,28 @@ +package dev.voir.formica.schema + +import kotlin.reflect.KProperty1 + +class Schema( + private val fields: List> +) { + fun validate(instance: T): ValidationResult { + val errors = buildList { + for (field in fields) { + @Suppress("UNCHECKED_CAST") + addAll((field as FieldSchema).validate(instance)) + } + } + return ValidationResult(errors) + } + + fun validateField( + instance: T, + property: KProperty1 + ): List { + val field = fields.firstOrNull { it.property == property } + ?: error("Property '${property.name}' is not registered in schema") + + @Suppress("UNCHECKED_CAST") + return (field as FieldSchema).validate(instance) + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt new file mode 100644 index 0000000..40668b1 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt @@ -0,0 +1,26 @@ +package dev.voir.formica.schema + +import kotlin.reflect.KProperty1 + +class SchemaBuilder { + private val fields = mutableListOf>() + + fun field( + property: KProperty1, + block: FieldBuilder.() -> Unit + ) { + val builder = FieldBuilder() + builder.block() + fields += FieldSchema(property, builder.build()) + } + + internal fun build(): Schema = Schema(fields) +} + +fun schema( + block: SchemaBuilder.() -> Unit +): Schema { + val builder = SchemaBuilder() + builder.block() + return builder.build() +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt new file mode 100644 index 0000000..986af62 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt @@ -0,0 +1,7 @@ +package dev.voir.formica.schema + +class ValidationException( + val validationErrors: List +) : IllegalArgumentException( + validationErrors.joinToString(separator = "\n") { "${it.field}: ${it.message}" } +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt new file mode 100644 index 0000000..b8898f7 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt @@ -0,0 +1,13 @@ +package dev.voir.formica.schema + +data class ValidationResult( + val errors: List +) { + val isValid: Boolean get() = errors.isEmpty() + + fun throwIfInvalid() { + if (errors.isNotEmpty()) { + throw ValidationException(errors) + } + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt new file mode 100644 index 0000000..d0fc6fc --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt @@ -0,0 +1,5 @@ +package dev.voir.formica.schema + +fun interface ValidationRule { + fun validate(value: T): FieldValidationResult +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt new file mode 100644 index 0000000..efe40c5 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt @@ -0,0 +1,350 @@ +package dev.voir.formica.schema + +object ValidationRules { + fun validateOnlyIf(active: () -> Boolean, rule: ValidationRule) = + ValidationRule { v -> + if (active()) rule.validate( + v + ) else FieldValidationResult.Success + } + + fun required( + message: String = "Field is required", + isEmpty: (V?) -> Boolean = { v -> + when (v) { + null -> true + is String -> v.isBlank() + is Collection<*> -> v.isEmpty() + else -> false + } + } + ): ValidationRule = + ValidationRule { v -> + if (isEmpty(v)) { + FieldValidationResult.Error(message) + } else { + FieldValidationResult.Success + } + } + + /* TODO Maybe useful + fun requiredIf( + form: Formica, + predicate: (D) -> Boolean, + message: String + ): ValidationRule = ValidationRule { v -> + val active = predicate(form.data.value) // read live snapshot + if (!active) return@ValidationRule FieldResult.Success + + val empty = when (v) { + null -> true + is String -> v.isBlank() + is Collection<*> -> v.isEmpty() + else -> false + } + if (empty) FieldResult.Error(message) else FieldResult.Success + }*/ + + + fun notEmpty( + message: String = "This field cannot be empty." + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FieldValidationResult.Skip + v.isNotEmpty() -> FieldValidationResult.Success + else -> FieldValidationResult.Error(message) + } + } + + fun notBlank( + message: String = "This field cannot be blank." + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FieldValidationResult.Skip + v.isNotBlank() -> FieldValidationResult.Success + else -> FieldValidationResult.Error(message) + } + } + + fun email( + message: String = "Must be a valid email address.", + pattern: Regex = EMAIL_PATTERN.toRegex() + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FieldValidationResult.Skip + v.matches(pattern) -> FieldValidationResult.Success + else -> FieldValidationResult.Error(message) + } + } + + fun strongPassword( + minLength: Int = 8, + lengthMessage: String = "Password must be at least $minLength characters long.", + uppercaseMessage: String = "Password must contain at least one uppercase letter.", + lowercaseMessage: String = "Password must contain at least one lowercase letter.", + digitMessage: String = "Password must contain at least one digit.", + specialCharacterMessage: String = "Password must contain at least one special character.", + ): ValidationRule = + ValidationRule { v -> + when { + v == null -> FieldValidationResult.Skip + v.length < minLength -> FieldValidationResult.Error( + lengthMessage + ) + + !v.any { it.isUpperCase() } -> FieldValidationResult.Error( + uppercaseMessage + ) + + !v.any { it.isLowerCase() } -> FieldValidationResult.Error( + lowercaseMessage + ) + + !v.any { it.isDigit() } -> FieldValidationResult.Error( + digitMessage + ) + + !v.any { it in "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" } -> FieldValidationResult.Error( + specialCharacterMessage + ) + + else -> FieldValidationResult.Success + } + } + + fun url( + protocolRequired: Boolean = false, + message: String = "Must be a valid URL." + ): ValidationRule = + ValidationRule { v -> + if (v == null) { + return@ValidationRule FieldValidationResult.Skip + } + + val result = if (protocolRequired) { + v.matches(HTTP_URL_PATTERN.toRegex()) + } else { + v.matches(HTTP_URL_PATTERN.toRegex()) || v.matches(DOMAIN_URL_PATTERN.toRegex()) + } + + if (result) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message) + } + } + + fun checked(message: String = "Must be checked"): ValidationRule = + ValidationRule { v -> + if (v == null) { + return@ValidationRule FieldValidationResult.Skip + } + + if (v) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message) + } + } + + fun minLength( + option: Int, + message: String = "Must be at least $option characters long.", + ): ValidationRule = + ValidationRule { v -> + if (v == null) { + return@ValidationRule FieldValidationResult.Skip + } + if (v.count() >= option) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message) + } + } + + fun maxLength( + option: Int, + message: String = "Must not exceed $option characters.", + ): ValidationRule = + ValidationRule { v -> + if (v == null) { + return@ValidationRule FieldValidationResult.Skip + } + if (v.count() <= option) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message) + } + } + + fun range( + min: T, + max: T, + inclusive: Boolean = true, + message: (min: T, max: T) -> String = { lo, hi -> "Must be a number between $lo and $hi." } + ): ValidationRule where T : Number, T : Comparable = + ValidationRule { v -> + if (v == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v >= min && v <= max else v > min && v < max + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(min, max) + ) + } + + fun range( + min: Double, + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double, Double) -> String = { lo, hi -> "Must be a number between $lo and $hi." } + ): ValidationRule = + ValidationRule { v -> + if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip + val lower = if (inclusive) v >= min - epsilon else v > min + epsilon + val upper = if (inclusive) v <= max + epsilon else v < max - epsilon + if (lower && upper) FieldValidationResult.Success else FieldValidationResult.Error( + message( + min, + max + ) + ) + } + + fun range( + min: Float, + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float, Float) -> String = { lo, hi -> "Must be a number between $lo and $hi." } + ): ValidationRule = + ValidationRule { v -> + if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip + val lower = if (inclusive) v >= min - epsilon else v > min + epsilon + val upper = if (inclusive) v <= max + epsilon else v < max - epsilon + if (lower && upper) FieldValidationResult.Success else FieldValidationResult.Error( + message( + min, + max + ) + ) + } + + fun min( + min: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v >= min else v > min + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(min) + ) + } + + fun max( + max: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v <= max else v < max + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(max) + ) + } + + fun min( + min: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v >= min else v > min + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(min) + ) + } + + fun max( + max: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v <= max else v < max + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(max) + ) + } + + fun min( + min: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v >= min - epsilon else v > min + epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(min) + ) + } + + fun max( + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v <= max + epsilon else v < max - epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(max) + ) + } + + fun min( + min: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v >= min - epsilon else v > min + epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(min) + ) + } + + fun max( + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { v -> + if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) v <= max + epsilon else v < max - epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error( + message(max) + ) + } +} + +internal const val EMAIL_PATTERN = "[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + + "(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+" + +internal const val DOMAIN_URL_PATTERN = + "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" +internal const val HTTP_URL_PATTERN = + "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" From 646c0137c28fbc79e9b34b25368e21088f82a90a Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Fri, 17 Apr 2026 17:45:20 +0200 Subject: [PATCH 2/6] Formica core --- .../kotlin/dev/voir/formica/Formica.kt | 188 ------- .../kotlin/dev/voir/formica/FormicaField.kt | 164 ------ .../kotlin/dev/voir/formica/FormicaFieldId.kt | 36 -- .../kotlin/dev/voir/formica/FormicaResults.kt | 18 - .../core/DefaultFormicaFieldDefinition.kt | 19 + .../kotlin/dev/voir/formica/core/Formica.kt | 340 +++++++++++ .../dev/voir/formica/core/FormicaFactory.kt | 14 + .../formica/core/FormicaFieldDefinition.kt | 45 ++ .../voir/formica/core/FormicaFieldSnapshot.kt | 18 + .../voir/formica/core/FormicaFieldState.kt | 87 +++ .../core/FormicaFieldValidationResult.kt | 14 + .../voir/formica/core/FormicaFormSnapshot.kt | 18 + .../voir/formica/core/FormicaSubmitResult.kt | 13 + .../formica/core/FormicaValidationAdapter.kt | 37 ++ .../formica/core/FormicaValidationResult.kt | 15 + .../dev/voir/formica/schema/FieldBuilder.kt | 223 -------- .../dev/voir/formica/schema/FieldError.kt | 6 - .../dev/voir/formica/schema/FieldSchema.kt | 26 - .../formica/schema/FieldValidationResult.kt | 7 - .../kotlin/dev/voir/formica/schema/Schema.kt | 28 - .../dev/voir/formica/schema/SchemaBuilder.kt | 26 - .../formica/schema/ValidationException.kt | 7 - .../voir/formica/schema/ValidationResult.kt | 13 - .../dev/voir/formica/schema/ValidationRule.kt | 5 - .../voir/formica/schema/ValidationRules.kt | 350 ------------ .../dev/voir/formica/ui/FormicaField.kt | 195 ------- .../voir/formica/ui/FormicaFieldPresence.kt | 76 --- .../dev/voir/formica/ui/FormicaFieldState.kt | 106 ---- .../dev/voir/formica/ui/FormicaProvider.kt | 99 ---- .../dev/voir/formica/FormicaFieldTest.kt | 224 -------- .../kotlin/dev/voir/formica/FormicaTest.kt | 238 -------- .../dev/voir/formica/ValidationRulesTest.kt | 305 ---------- .../dev/voir/formica/core/FormicaTest.kt | 529 ++++++++++++++++++ 33 files changed, 1149 insertions(+), 2340 deletions(-) delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/DefaultFormicaFieldDefinition.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/Formica.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFactory.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldDefinition.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldSnapshot.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldState.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldValidationResult.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFormSnapshot.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaSubmitResult.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationAdapter.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationResult.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt delete mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt delete mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt delete mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt delete mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/ValidationRulesTest.kt create mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/core/FormicaTest.kt diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt deleted file mode 100644 index 3145490..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/Formica.kt +++ /dev/null @@ -1,188 +0,0 @@ -package dev.voir.formica - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - - -/** - * A form state container that: - * - Holds a reactive [data] snapshot (immutable [Data]) - * - Manages a registry of fields (each field keeps its own reactive state & validation) - * - Provides typed updates via [onChange], and form-level [validate]/[submit] workflows - * - Supports clearing & syncing from external data sources - * - * ### Key ideas - * - **No reflection:** fields are registered with [FormicaFieldId] (lens-like accessors). - * - **Immutable data:** every write returns a new [Data], updating [_data] so UI can react. - * - **Field state is separate:** each field maintains `value/error/result/touched/dirty`, but the - * canonical committed model is always [data]. - * - **Validation pipeline:** `FormicaField` handles required/validators/custom. Here we just call `validate()` - * across all registered fields and aggregate errors. - */ -class Formica(val initialData: Data, private val onSubmit: ((Data) -> Unit)? = null) { - /** - * Registry of fields by their [FormicaFieldId.id]. - * Pair = (lens, field-state). - * - * NOTE: We use `Any?` erasure behind the scenes; public APIs remain strongly typed. - */ - private val fields = - mutableMapOf, FormicaField>>() - - /** - * Reactive, immutable snapshot of the entire form data model. - * Every successful [onChange]/[clear] produces a new instance. - */ - private val _data = MutableStateFlow(initialData) - val data: StateFlow get() = _data - - /** - * Reactive result of the last form-level validation/submit attempt. - * - Starts as [FormicaResult.NoInput] - * - Changes to [FormicaResult.Valid] or [FormicaResult.Error] after [validate]/[submit] - */ - private val _result = MutableStateFlow(FormicaResult.NoInput) - val result: StateFlow get() = _result - - /** - * Register a field for this form. Must be called once per field you plan to use. - * - * @param id Lens describing how to read/write the field on [Data]. - * @param validators ORDERED set of validators for the field (first failure short-circuits). - * (Kotlin's default `setOf(...)` is insertion-ordered / LinkedHashSet.) - * @param customValidation Optional rule run *after* all validators. - * @param validateOnChange If true, field validates automatically on value changes. - * - * @return The created [FormicaField] (reactive value/error/etc.). - * - * You can conditionally *render* inputs, but you should *register* fields once to keep - * state stable across composition toggles. - */ - fun registerField( - id: FormicaFieldId, - validators: Set>, - customValidation: ((Value?) -> FormicaFieldResult)? = null, - validateOnChange: Boolean = true, - ): FormicaField { - val field = FormicaField( - initialValue = id.get(_data.value), // seed from current data snapshot - validators = validators, - customValidation = customValidation, - validateOnChange = validateOnChange - ) - // Store in registry with erased generics - @Suppress("UNCHECKED_CAST") - fields[id.id] = (id as FormicaFieldId) to (field as FormicaField) - - return field - } - - /** - * Retrieve a previously registered field (for reading value/error/touched/dirty externally). - * Returns null if the field hasn't been registered in this form. - */ - fun getRegisteredField(id: FormicaFieldId): FormicaField? { - val pair = fields[id.id] ?: return null - @Suppress("UNCHECKED_CAST") - return pair.second as? FormicaField - } - - /** - * Apply a single-field update and propagate it to both: - * 1) The field's reactive state (value/touched/dirty and optional per-change validation) - * 2) The immutable [data] snapshot via lens set/clear - * - * @param id Lens for the field - * @param value New value, or `null` to indicate "clear". If `null` and [FormicaFieldId.clear] is not set, - * the data snapshot remains unchanged (field state still updates). - */ - fun onChange(id: FormicaFieldId, value: V?) { - val pair = fields[id.id] ?: return - val (lens, field) = pair - - // 1) Update field reactive state (value/touched/dirty + maybe validate) - @Suppress("UNCHECKED_CAST") - (field as FormicaField).onChange(value) - - // 2) Update immutable data snapshot via lens - @Suppress("UNCHECKED_CAST") - val l = lens as FormicaFieldId - _data.value = if (value == null) { - l.clear?.invoke(_data.value) ?: _data.value - } else { - l.set(_data.value, value) - } - } - - /** - * Validate all registered fields and update [result] accordingly. - * - * @return [FormicaResult.Valid] if all fields are valid, otherwise [FormicaResult.Error] - * with a map of `fieldId -> message`. - * - * Field-level validation order is handled inside each [FormicaField]. - */ - fun validate(): FormicaResult { - val errors = mutableMapOf() - - for ((key, pair) in fields) { - val (_, field) = pair - val res = field.validate() - if (res is FormicaFieldResult.Error) { - errors[key] = res.message - } - } - - val newState = if (errors.isEmpty()) { - FormicaResult.Valid - } else { - FormicaResult.Error( - message = "Some fields are not valid", // TODO This message can be changed - fieldErrors = errors.toMap() - ) - } - - _result.value = newState - return newState - } - - /** - * Bring all registered fields' initial/value state back in sync with the current [data] snapshot. - * - * Useful after you changed [_data] externally (e.g., loaded a draft, applied a server patch). - * Resets each field to "pristine" (NoInput, not dirty/touched). - */ - fun syncFromData() { - for ((_, pair) in fields) { - val (lens, field) = pair - val v = lens.get(_data.value) - field.reset(v) - } - _result.value = FormicaResult.NoInput - } - - /** - * Validate and, if successful, invoke [onSubmit] with the latest [data] snapshot. - * - * @return the validation result so callers can branch on it. - */ - fun submit(): FormicaResult { - val r = validate() - if (r is FormicaResult.Valid) { - onSubmit?.invoke(_data.value) - } - return r - } - - /** - * Explicitly clear a field on the immutable [data] snapshot using its [FormicaFieldId.clear] - * (if provided). Does *not* touch the field's reactive value; combine with - * `getRegisteredField(...).reset(...)` or `onChange(id, null)` if you also want to - * update the field state. - */ - fun clear(id: FormicaFieldId) { - val pair = fields[id.id] ?: return - val lens = pair.first - (lens.clear ?: return)(_data.value).also { _data.value = it } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt deleted file mode 100644 index 8041320..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaField.kt +++ /dev/null @@ -1,164 +0,0 @@ -package dev.voir.formica - -import kotlinx.coroutines.flow.MutableStateFlow - -/** - * Represents a single field inside a form. - * - * This class encapsulates: - * - The field's current value - * - Validation rules and results - * - UI-friendly state flags (touched, dirty) - * - Optional enable/disable (presence) flag - * - * Generic type [Value] can be nullable or non-null, but internally we always store it - * as `Value?` in [value] to allow temporarily "empty" states. - */ -class FormicaField( - initialValue: Value, - /** - * A set of built-in or attached validation rules to run in order. - * Each rule is a [ValidationRule] applied before [customValidation]. - * Order matters: the first failing rule will short-circuit validation. - */ - private val validators: Set> = emptySet(), - - /** - * A custom validation function run *after* all [validators]. - * Allows for cross-field or complex validation logic. - */ - private val customValidation: ((Value?) -> FormicaFieldResult)? = null, - - /** - * Whether the field should validate itself automatically every time its value changes. - * If false, you must call [validate] manually (e.g., on form submit). - */ - private val validateOnChange: Boolean = true, -) { - /** - * Current value of the field (nullable so that "empty" can be represented). - * Observed by UI to display current input. - */ - val value = MutableStateFlow(initialValue) - - /** - * The last error message for this field, or null if valid. - * Updated during validation. Observed by UI to show error messages. - */ - val error = MutableStateFlow(null) - - /** - * The last validation result: Success, Error, or NoInput. - */ - val result = MutableStateFlow(FormicaFieldResult.NoInput) - - /** - * Whether the field has been interacted with (first change made). - * Useful for deciding when to show validation errors (e.g., on blur). - */ - val touched = MutableStateFlow(false) - - /** - * Whether the value has changed from its initial value. - * Often used to enable/disable a "Save" button. - */ - val dirty = MutableStateFlow(false) - - /** - * Snapshot of the initial value for dirty checking and reset logic. - */ - private var initial = initialValue - - /** - * Whether the field is "enabled" (present in form) for validation purposes. - * If disabled, validation will always return Success and skip validators. - */ - private val enabled = MutableStateFlow(true) - - /** - * Enable or disable the field for validation. - * When disabled, validators and customValidation will be skipped. - */ - fun setEnabled(v: Boolean) { - enabled.value = v - } - - - /** - * Called when the user changes the value. - * - * Updates: - * - [touched]: true after the first change - * - [dirty]: true if value != initial - * - [value]: set to new input - * - * Optionally triggers validation immediately if [validateOnChange] is true. - */ - fun onChange(input: Value?) { - if (!touched.value) touched.value = true - dirty.value = (input != initial) - value.value = input - if (validateOnChange) validate() - } - - - /** - * Reset the field to a new initial value. - * Clears errors and flags, resets result to NoInput. - */ - fun reset(newInitial: Value) { - initial = newInitial - value.value = newInitial - error.value = null - result.value = FormicaFieldResult.NoInput - touched.value = false - dirty.value = false - } - - /** - * Convenience: return true if [validate] passes. - */ - fun isValid(): Boolean = validate() is FormicaFieldResult.Success - - - /** - * Run validation on the current value: - * - * 1. If disabled ([enabled] == false), skip and mark Success. - * 2. Run [validators] in order, short-circuit on first Error. - * 3. Run [customValidation] last if present. - * 4. Store final result in [result] and update [error] if applicable. - */ - fun validate(): FormicaFieldResult { - // Disabled? skip everything and mark as valid - if (!enabled.value) { - error.value = null - result.value = FormicaFieldResult.Success - return FormicaFieldResult.Success - } - - val v = value.value - - // Run built-in/attached validators first (ordered) - for (rule in validators) { - when (val r = rule.validate(v)) { - is FormicaFieldResult.Error -> return setError(r) - else -> {} - } - } - - // Custom validation last - val r = customValidation?.invoke(v) ?: FormicaFieldResult.Success - return setError(r) - } - - - /** - * Internal helper to update [result] and [error] flows together. - */ - private fun setError(r: FormicaFieldResult): FormicaFieldResult { - result.value = r - error.value = (r as? FormicaFieldResult.Error)?.message - return r - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt deleted file mode 100644 index decd208..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaFieldId.kt +++ /dev/null @@ -1,36 +0,0 @@ -package dev.voir.formica - -/** - * A lightweight, reflection-free *lens* describing how to read/write a single field [V] on a model [Data]. - * - * - [id] must be a stable, unique key (e.g., "firstName"). It's used to store/lookup the field in the form. - * - [get] reads the current value of the field from [Data]. - * - [set] returns a **new** [Data] instance with the field updated (immutability by design). - * - [clear] optionally returns a **new** [Data] with the field cleared (e.g., to `null` or default), - * used when `onChange(..., value = null)` is invoked. If absent, `null` updates are ignored. - * - * This avoids kotlin-reflect and works great for KMP. Define them next to your data model: - * - * ```kotlin - * data class Profile(val firstName: String, val note: String?) - * - * val FirstName = FormicaFieldId( - * id = "firstName", - * get = { it.firstName }, - * set = { d, v -> d.copy(firstName = v) } - * ) - * - * val Note = FormicaFieldId( - * id = "note", - * get = { it.note }, - * set = { d, v -> d.copy(note = v) }, - * clear = { d -> d.copy(note = null) } - * ) - * ``` - */ -class FormicaFieldId( - val id: String, // Stable key (e.g., "firstName") - val get: (Data) -> V, // Read from Data - val set: (Data, V) -> Data, // Return a *new* Data with V set (immutable update) - val clear: ((Data) -> Data)? = null // Optional immutable "clear" operation -) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt b/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt deleted file mode 100644 index c31d6a2..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/FormicaResults.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.voir.formica - -sealed class FormicaResult { - data object NoInput : FormicaResult() - data object Valid : FormicaResult() - data class Error( - val message: String, - val fieldErrors: Map = emptyMap() - ) : FormicaResult() -} - -sealed class FormicaFieldResult { - data object Success : FormicaFieldResult() - - data class Error(val message: String) : FormicaFieldResult() - - data object NoInput : FormicaFieldResult() -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/DefaultFormicaFieldDefinition.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/DefaultFormicaFieldDefinition.kt new file mode 100644 index 0000000..8b8f9fe --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/DefaultFormicaFieldDefinition.kt @@ -0,0 +1,19 @@ +package dev.voir.formica.core + +import kotlin.reflect.KProperty1 + +/** + * Small default implementation of a field definition. + * + * Most adapters/schemas can reuse this instead of implementing + * FormicaFieldDefinition manually every time. + */ +data class DefaultFormicaFieldDefinition( + override val key: String, + override val property: KProperty1, + override val set: (Data, Value?) -> Data, + override val clear: ((Data) -> Data)? = null, + override val validateOnChange: Boolean = true, + override val isVisible: (Data) -> Boolean = { true }, + override val isEnabled: (Data) -> Boolean = { true } +) : FormicaFieldDefinition diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/Formica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/Formica.kt new file mode 100644 index 0000000..d387c38 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/Formica.kt @@ -0,0 +1,340 @@ +package dev.voir.formica.core + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlin.reflect.KProperty1 + +/** + * Core form state container. + * + * Responsibilities: + * - owns current immutable data snapshot + * - owns reactive runtime state for each field + * - delegates validation to the provided adapter + * - exposes typed field access and immutable updates + * + * Non-responsibilities: + * - does not define validation rules + * - does not depend on Compose + */ +class Formica( + private val adapter: FormicaValidationAdapter, + initialData: Data, + private val onSubmit: ((Data) -> Unit)? = null +) { + private val initialDataSnapshot: Data = initialData + + private val definitionsByKey: Map> = + adapter.fields.associateBy { it.key } + + private val definitionsByProperty: Map, FormicaFieldDefinition> = + adapter.fields.associateBy { it.property } + + private val fieldStates: Map> = + adapter.fields.associate { field -> + val visible = field.isVisible(initialData) + val enabled = field.isEnabled(initialData) + + @Suppress("UNCHECKED_CAST") + val typedField = field as FormicaFieldDefinition + + field.key to FormicaFieldState( + initialValue = typedField.property.get(initialData), + initialVisible = visible, + initialEnabled = enabled + ) + } + + private val _data = MutableStateFlow(initialData) + val data: StateFlow get() = _data + + private val _submitResult = MutableStateFlow(FormicaSubmitResult.NoAttempt) + val submitResult: StateFlow get() = _submitResult + + private val _isDirty = MutableStateFlow(false) + val isDirty: StateFlow get() = _isDirty + + private val _isTouched = MutableStateFlow(false) + val isTouched: StateFlow get() = _isTouched + + private val _hasErrors = MutableStateFlow(false) + val hasErrors: StateFlow get() = _hasErrors + + private val _canSubmit = MutableStateFlow(false) + val canSubmit: StateFlow get() = _canSubmit + + private val _fieldErrors = MutableStateFlow>(emptyMap()) + val fieldErrors: StateFlow> get() = _fieldErrors + + private val _formErrors = MutableStateFlow>(emptyList()) + val formErrors: StateFlow> get() = _formErrors + + init { + refreshAvailability() + refreshDerivedState() + } + + /** + * Typed access to a field state by property reference. + */ + fun fieldState(property: KProperty1): FormicaFieldState { + val definition = findDefinition(property) + val state = fieldStates[definition.key] + ?: error("Missing field state for key '${definition.key}'") + + @Suppress("UNCHECKED_CAST") + return state as FormicaFieldState + } + + /** + * Access a field snapshot by property reference. + */ + fun fieldSnapshot(property: KProperty1): FormicaFieldSnapshot { + val definition = findDefinition(property) + val state = fieldState(property) + return FormicaFieldSnapshot( + key = definition.key, + value = state.value.value, + error = state.error.value, + validationResult = state.validationResult.value, + touched = state.touched.value, + dirty = state.dirty.value, + visible = state.visible.value, + enabled = state.enabled.value + ) + } + + /** + * Apply immutable field update + runtime field state update. + * + * If validateOnChange is enabled for the field, field validation runs immediately. + */ + fun onChange(property: KProperty1, value: Value?) { + val definition = findDefinition(property) + val state = fieldState(property) + + state.onChange(value) + + _data.value = definition.set(_data.value, value) + + refreshAvailability() + + if (definition.validateOnChange) { + validateField(property) + } else { + state.clearValidation() + } + + refreshDerivedState() + } + + /** + * Clear a field using the field definition's clear function, if present. + */ + fun clear(property: KProperty1) { + val definition = findDefinition(property) + val clearFn = definition.clear ?: return + + _data.value = clearFn(_data.value) + + @Suppress("UNCHECKED_CAST") + val typedDefinition = definition as FormicaFieldDefinition + + val newValue = typedDefinition.property.get(_data.value) + val state = fieldState(property) + state.onChange(newValue) + + refreshAvailability() + + if (definition.validateOnChange) { + validateField(property) + } else { + state.clearValidation() + } + + refreshDerivedState() + } + + /** + * Validate a single field through the adapter. + * + * Hidden/disabled fields are treated as skipped. + */ + fun validateField(property: KProperty1): FormicaFieldValidationResult { + val definition = findDefinition(property) + val state = fieldState(property) + + val updatedFieldErrors = _fieldErrors.value.toMutableMap() + + if (!state.visible.value || !state.enabled.value) { + state.setValidationResult(FormicaFieldValidationResult.Skip) + updatedFieldErrors.remove(definition.key) + _fieldErrors.value = updatedFieldErrors + refreshDerivedState() + return FormicaFieldValidationResult.Skip + } + + val result = adapter.validateField(_data.value, definition.key) + state.setValidationResult(result) + + when (result) { + is FormicaFieldValidationResult.Error -> { + updatedFieldErrors[definition.key] = result.message + } + + else -> { + updatedFieldErrors.remove(definition.key) + } + } + + _fieldErrors.value = updatedFieldErrors + refreshDerivedState() + return result + } + + /** + * Validate the whole form through the adapter. + * + * Also synchronizes field-level error state with adapter results. + */ + fun validate(): FormicaValidationResult { + val rawValidation = adapter.validate(_data.value) + + val filteredFieldErrors = buildMap { + for (field in adapter.fields) { + val state = fieldStates[field.key] ?: continue + val visible = state.visible.value + val enabled = state.enabled.value + + if (!visible || !enabled) { + state.setValidationResult(FormicaFieldValidationResult.Skip) + continue + } + + val error = rawValidation.fieldErrors[field.key] + if (error == null) { + state.setValidationResult(FormicaFieldValidationResult.Success) + } else { + state.setValidationResult(FormicaFieldValidationResult.Error(error)) + put(field.key, error) + } + } + } + + val validation = FormicaValidationResult( + data = rawValidation.data, + fieldErrors = filteredFieldErrors, + formErrors = rawValidation.formErrors + ) + + _fieldErrors.value = validation.fieldErrors + _formErrors.value = validation.formErrors + _submitResult.value = if (validation.isValid) { + FormicaSubmitResult.Success + } else { + FormicaSubmitResult.Error( + fieldErrors = validation.fieldErrors, + formErrors = validation.formErrors + ) + } + + refreshDerivedState() + return validation + } + + /** + * Validate and call submit callback if valid. + */ + fun submit(): FormicaValidationResult { + val validation = validate() + if (validation.isValid) { + onSubmit?.invoke(validation.data) + } + return validation + } + + /** + * Reset the form to a provided snapshot or back to the initial one. + */ + fun reset(to: Data = initialDataSnapshot) { + _data.value = to + + for (field in adapter.fields) { + @Suppress("UNCHECKED_CAST") + val typedField = field as FormicaFieldDefinition + val state = fieldStates[field.key] ?: continue + + state.reset( + newInitial = typedField.property.get(to), + visible = field.isVisible(to), + enabled = field.isEnabled(to) + ) + } + + _fieldErrors.value = emptyMap() + _formErrors.value = emptyList() + _submitResult.value = FormicaSubmitResult.NoAttempt + refreshDerivedState() + } + + /** + * Replace current data snapshot and mark the current values as pristine. + * + * Useful when loading a draft or replacing form data from an external source. + */ + fun replaceData(data: Data) { + reset(data) + } + + /** + * Create an immutable snapshot of current form state. + */ + fun snapshot(): FormicaFormSnapshot = + FormicaFormSnapshot( + data = _data.value, + isDirty = _isDirty.value, + isTouched = _isTouched.value, + hasErrors = _hasErrors.value, + canSubmit = _canSubmit.value, + submitResult = _submitResult.value, + fieldErrors = _fieldErrors.value, + formErrors = _formErrors.value + ) + + private fun refreshAvailability() { + val current = _data.value + + for (field in adapter.fields) { + val state = fieldStates[field.key] ?: continue + state.updateAvailability( + visible = field.isVisible(current), + enabled = field.isEnabled(current) + ) + } + } + + private fun refreshDerivedState() { + val states = fieldStates.values + + val isDirtyNow = states.any { it.dirty.value } + val isTouchedNow = states.any { it.touched.value } + val hasErrorsNow = states.any { state -> + state.visible.value && + state.enabled.value && + state.error.value != null + } + + _isDirty.value = isDirtyNow + _isTouched.value = isTouchedNow + _hasErrors.value = hasErrorsNow + _canSubmit.value = isDirtyNow && !hasErrorsNow + } + + private fun findDefinition( + property: KProperty1 + ): FormicaFieldDefinition { + @Suppress("UNCHECKED_CAST") + return definitionsByProperty[property] as? FormicaFieldDefinition + ?: error("Field '${property.name}' is not registered in adapter") + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFactory.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFactory.kt new file mode 100644 index 0000000..642298a --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFactory.kt @@ -0,0 +1,14 @@ +package dev.voir.formica.core + +/** + * Convenience factory. + */ +fun formica( + adapter: FormicaValidationAdapter, + initialData: Data, + onSubmit: ((Data) -> Unit)? = null +): Formica = Formica( + adapter = adapter, + initialData = initialData, + onSubmit = onSubmit +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldDefinition.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldDefinition.kt new file mode 100644 index 0000000..3119b44 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldDefinition.kt @@ -0,0 +1,45 @@ +package dev.voir.formica.core + +import kotlin.reflect.KProperty1 + +/** + * Describes one field known to the form engine. + * + * The core runtime uses: + * - key -> stable identity inside the form + * - property -> typed getter + * - set -> immutable update function + * - clear -> optional "remove/reset" update function + * + * Validation is NOT defined here directly. That belongs to the adapter. + */ +interface FormicaFieldDefinition { + val key: String + val property: KProperty1 + + /** + * Immutable update of a field value into the parent data object. + */ + val set: (Data, Value?) -> Data + + /** + * Optional immutable clear/reset operation for this field. + */ + val clear: ((Data) -> Data)? + + /** + * Whether Formica should validate this field automatically when it changes. + */ + val validateOnChange: Boolean + + /** + * Whether this field should be visible for the current data snapshot. + */ + val isVisible: (Data) -> Boolean + + /** + * Whether this field should be enabled for the current data snapshot. + * Disabled fields are ignored for interaction/validation purposes. + */ + val isEnabled: (Data) -> Boolean +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldSnapshot.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldSnapshot.kt new file mode 100644 index 0000000..20184db --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldSnapshot.kt @@ -0,0 +1,18 @@ +package dev.voir.formica.core + +/** + * Immutable snapshot of one field. + * + * Useful for consumers that want a read-only summary + * without directly observing all individual flows. + */ +data class FormicaFieldSnapshot( + val key: String, + val value: Value?, + val error: String?, + val validationResult: FormicaFieldValidationResult, + val touched: Boolean, + val dirty: Boolean, + val visible: Boolean, + val enabled: Boolean +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldState.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldState.kt new file mode 100644 index 0000000..5fd6e8e --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldState.kt @@ -0,0 +1,87 @@ +package dev.voir.formica.core + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Reactive runtime state of a single field. + * + * This class contains UI/runtime state only. + * It does not know validation rules or schema internals. + */ +class FormicaFieldState( + initialValue: Value?, + initialVisible: Boolean, + initialEnabled: Boolean +) { + private var initial: Value? = initialValue + + private val _value = MutableStateFlow(initialValue) + val value: StateFlow get() = _value + + private val _error = MutableStateFlow(null) + val error: StateFlow get() = _error + + private val _validationResult = + MutableStateFlow(FormicaFieldValidationResult.Skip) + val validationResult: StateFlow get() = _validationResult + + private val _touched = MutableStateFlow(false) + val touched: StateFlow get() = _touched + + private val _dirty = MutableStateFlow(false) + val dirty: StateFlow get() = _dirty + + private val _visible = MutableStateFlow(initialVisible) + val visible: StateFlow get() = _visible + + private val _enabled = MutableStateFlow(initialEnabled) + val enabled: StateFlow get() = _enabled + + /** + * Called when the user changes the field value. + */ + fun onChange(newValue: Value?) { + if (!_touched.value) _touched.value = true + _value.value = newValue + _dirty.value = newValue != initial + } + + /** + * Reset field to a new initial value and clear all transient UI state. + */ + fun reset(newInitial: Value?, visible: Boolean, enabled: Boolean) { + initial = newInitial + _value.value = newInitial + _error.value = null + _validationResult.value = FormicaFieldValidationResult.Skip + _touched.value = false + _dirty.value = false + _visible.value = visible + _enabled.value = enabled + } + + /** + * Used when only visibility/enabled state changes. + */ + fun updateAvailability(visible: Boolean, enabled: Boolean) { + _visible.value = visible + _enabled.value = enabled + } + + /** + * Store last validation result and synced error text. + */ + fun setValidationResult(result: FormicaFieldValidationResult) { + _validationResult.value = result + _error.value = (result as? FormicaFieldValidationResult.Error)?.message + } + + /** + * Clear validation state without touching value/touched/dirty. + */ + fun clearValidation() { + _validationResult.value = FormicaFieldValidationResult.Skip + _error.value = null + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldValidationResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldValidationResult.kt new file mode 100644 index 0000000..d004210 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFieldValidationResult.kt @@ -0,0 +1,14 @@ +package dev.voir.formica.core + +/** + * Result of validating a single field. + * + * Success = valid + * Skip = intentionally not validated (e.g. hidden/disabled/not applicable) + * Error = invalid with message + */ +sealed interface FormicaFieldValidationResult { + data object Success : FormicaFieldValidationResult + data object Skip : FormicaFieldValidationResult + data class Error(val message: String) : FormicaFieldValidationResult +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFormSnapshot.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFormSnapshot.kt new file mode 100644 index 0000000..d905b3f --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaFormSnapshot.kt @@ -0,0 +1,18 @@ +package dev.voir.formica.core + +/** + * Immutable snapshot of the whole form. + * + * Helpful for debugging, tests, or UI layers that want + * a single aggregate view. + */ +data class FormicaFormSnapshot( + val data: Data, + val isDirty: Boolean, + val isTouched: Boolean, + val hasErrors: Boolean, + val canSubmit: Boolean, + val submitResult: FormicaSubmitResult, + val fieldErrors: Map, + val formErrors: List +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaSubmitResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaSubmitResult.kt new file mode 100644 index 0000000..7cb447f --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaSubmitResult.kt @@ -0,0 +1,13 @@ +package dev.voir.formica.core + +/** + * Last submit/validate status of the form itself. + */ +sealed interface FormicaSubmitResult { + data object NoAttempt : FormicaSubmitResult + data object Success : FormicaSubmitResult + data class Error( + val fieldErrors: Map, + val formErrors: List + ) : FormicaSubmitResult +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationAdapter.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationAdapter.kt new file mode 100644 index 0000000..8e3369d --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationAdapter.kt @@ -0,0 +1,37 @@ +package dev.voir.formica.core + +import kotlin.reflect.KProperty1 + +/** + * Pluggable validation contract. + * + * Your schema DSL can implement this. + * Another library can implement this too. + * Formica core only depends on this interface. + */ +interface FormicaValidationAdapter { + val fields: List> + + /** + * Validate the whole data object. + */ + fun validate(data: Data): FormicaValidationResult + + /** + * Validate a single field by field key. + */ + fun validateField( + data: Data, + fieldKey: String + ): FormicaFieldValidationResult +} + +/** + * Find a field definition by property reference. + */ +fun FormicaValidationAdapter.findField( + property: KProperty1 +): FormicaFieldDefinition? { + @Suppress("UNCHECKED_CAST") + return fields.firstOrNull { it.property == property } as? FormicaFieldDefinition +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationResult.kt new file mode 100644 index 0000000..b12d52c --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/core/FormicaValidationResult.kt @@ -0,0 +1,15 @@ +package dev.voir.formica.core + +/** + * Form-level validation output returned by adapters and by Formica.validate(). + * + * fieldErrors -> keyed by schema field key + * formErrors -> non-field/global errors + */ +data class FormicaValidationResult( + val data: Data, + val fieldErrors: Map = emptyMap(), + val formErrors: List = emptyList() +) { + val isValid: Boolean get() = fieldErrors.isEmpty() && formErrors.isEmpty() +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt deleted file mode 100644 index 5647e04..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldBuilder.kt +++ /dev/null @@ -1,223 +0,0 @@ -package dev.voir.formica.schema - -class FieldBuilder { - private val rules = mutableListOf>() - - fun rule(rule: ValidationRule) { - rules += rule - } - - internal fun build(): List> = rules -} - -fun FieldBuilder.required( - message: String = "Field is required", - isEmpty: (V?) -> Boolean = { v -> - when (v) { - null -> true - is String -> v.isBlank() - is Collection<*> -> v.isEmpty() - else -> false - } - } -) { - rule(ValidationRules.required(message, isEmpty)) -} - -fun FieldBuilder.validateOnlyIf( - active: () -> Boolean, - rule: ValidationRule -) { - rule(ValidationRules.validateOnlyIf(active, rule)) -} - -fun FieldBuilder.notEmpty( - message: String = "This field cannot be empty." -) { - rule(ValidationRules.notEmpty(message)) -} - -fun FieldBuilder.notBlank( - message: String = "This field cannot be blank." -) { - rule(ValidationRules.notBlank(message)) -} - -fun FieldBuilder.email( - message: String = "Must be a valid email address.", - pattern: Regex = EMAIL_PATTERN.toRegex() -) { - rule(ValidationRules.email(message, pattern)) -} - -fun FieldBuilder.strongPassword( - minLength: Int = 8, - lengthMessage: String = "Password must be at least $minLength characters long.", - uppercaseMessage: String = "Password must contain at least one uppercase letter.", - lowercaseMessage: String = "Password must contain at least one lowercase letter.", - digitMessage: String = "Password must contain at least one digit.", - specialCharacterMessage: String = "Password must contain at least one special character.", -) { - rule( - ValidationRules.strongPassword( - minLength, - lengthMessage, - uppercaseMessage, - lowercaseMessage, - digitMessage, - specialCharacterMessage - ) - ) -} - -fun FieldBuilder.url( - protocolRequired: Boolean = false, - message: String = "Must be a valid URL." -) { - rule(ValidationRules.url(protocolRequired, message)) -} - -fun FieldBuilder.minLength( - option: Int, - message: String = "Must be at least $option characters long." -) { - rule(ValidationRules.minLength(option, message)) -} - -fun FieldBuilder.maxLength( - option: Int, - message: String = "Must not exceed $option characters." -) { - rule(ValidationRules.maxLength(option, message)) -} - -fun FieldBuilder.notEmpty( - message: String = "This field cannot be empty." -) { - rule( - ValidationRule { v -> - if (v.isNotEmpty()) FieldValidationResult.Success - else FieldValidationResult.Error(message) - } - ) -} - -fun FieldBuilder.notBlank( - message: String = "This field cannot be blank." -) { - rule( - ValidationRule { v -> - if (v.isNotBlank()) FieldValidationResult.Success - else FieldValidationResult.Error(message) - } - ) -} - -fun FieldBuilder.email( - message: String = "Must be a valid email address.", - pattern: Regex = EMAIL_PATTERN.toRegex() -) { - rule( - ValidationRule { v -> - if (pattern.matches(v)) FieldValidationResult.Success - else FieldValidationResult.Error(message) - } - ) -} - -fun FieldBuilder.range( - min: N, - max: N, - inclusive: Boolean = true, - message: (N, N) -> String = { lo, hi -> "Must be a number between $lo and $hi." } -) where N : Number, N : Comparable { - rule(ValidationRules.range(min, max, inclusive, message)) -} - -fun FieldBuilder.range( - min: Double, - max: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double, Double) -> String = { lo, hi -> "Must be a number between $lo and $hi." } -) { - rule(ValidationRules.range(min, max, inclusive, epsilon, message)) -} - -fun FieldBuilder.range( - min: Float, - max: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float, Float) -> String = { lo, hi -> "Must be a number between $lo and $hi." } -) { - rule(ValidationRules.range(min, max, inclusive, epsilon, message)) -} - -fun FieldBuilder.min( - min: Int, - inclusive: Boolean = true, - message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } -) { - rule(ValidationRules.min(min, inclusive, message)) -} - -fun FieldBuilder.max( - max: Int, - inclusive: Boolean = true, - message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } -) { - rule(ValidationRules.max(max, inclusive, message)) -} - -fun FieldBuilder.min( - min: Long, - inclusive: Boolean = true, - message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } -) { - rule(ValidationRules.min(min, inclusive, message)) -} - -fun FieldBuilder.max( - max: Long, - inclusive: Boolean = true, - message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } -) { - rule(ValidationRules.max(max, inclusive, message)) -} - -fun FieldBuilder.min( - min: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } -) { - rule(ValidationRules.min(min, inclusive, epsilon, message)) -} - -fun FieldBuilder.max( - max: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } -) { - rule(ValidationRules.max(max, inclusive, epsilon, message)) -} - -fun FieldBuilder.min( - min: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } -) { - rule(ValidationRules.min(min, inclusive, epsilon, message)) -} - -fun FieldBuilder.max( - max: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } -) { - rule(ValidationRules.max(max, inclusive, epsilon, message)) -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt deleted file mode 100644 index 75184de..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldError.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.voir.formica.schema - -data class FieldError( - val field: String, - val message: String -) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt deleted file mode 100644 index ad1c574..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldSchema.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.voir.formica.schema - -import kotlin.reflect.KProperty1 - -class FieldSchema( - val property: KProperty1, - private val rules: List> -) { - val name: String get() = property.name - - fun validate(instance: T): List { - val value = property.get(instance) - - return buildList { - for (rule in rules) { - when (val result = rule.validate(value)) { - FieldValidationResult.Success, - FieldValidationResult.Skip -> Unit - - is FieldValidationResult.Error -> - add(FieldError(name, result.message)) - } - } - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt deleted file mode 100644 index 5db84ac..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.voir.formica.schema - -sealed interface FieldValidationResult { - data object Success : FieldValidationResult - data object Skip : FieldValidationResult - data class Error(val message: String) : FieldValidationResult -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt deleted file mode 100644 index b570d72..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt +++ /dev/null @@ -1,28 +0,0 @@ -package dev.voir.formica.schema - -import kotlin.reflect.KProperty1 - -class Schema( - private val fields: List> -) { - fun validate(instance: T): ValidationResult { - val errors = buildList { - for (field in fields) { - @Suppress("UNCHECKED_CAST") - addAll((field as FieldSchema).validate(instance)) - } - } - return ValidationResult(errors) - } - - fun validateField( - instance: T, - property: KProperty1 - ): List { - val field = fields.firstOrNull { it.property == property } - ?: error("Property '${property.name}' is not registered in schema") - - @Suppress("UNCHECKED_CAST") - return (field as FieldSchema).validate(instance) - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt deleted file mode 100644 index 40668b1..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.voir.formica.schema - -import kotlin.reflect.KProperty1 - -class SchemaBuilder { - private val fields = mutableListOf>() - - fun field( - property: KProperty1, - block: FieldBuilder.() -> Unit - ) { - val builder = FieldBuilder() - builder.block() - fields += FieldSchema(property, builder.build()) - } - - internal fun build(): Schema = Schema(fields) -} - -fun schema( - block: SchemaBuilder.() -> Unit -): Schema { - val builder = SchemaBuilder() - builder.block() - return builder.build() -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt deleted file mode 100644 index 986af62..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationException.kt +++ /dev/null @@ -1,7 +0,0 @@ -package dev.voir.formica.schema - -class ValidationException( - val validationErrors: List -) : IllegalArgumentException( - validationErrors.joinToString(separator = "\n") { "${it.field}: ${it.message}" } -) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt deleted file mode 100644 index b8898f7..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationResult.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.voir.formica.schema - -data class ValidationResult( - val errors: List -) { - val isValid: Boolean get() = errors.isEmpty() - - fun throwIfInvalid() { - if (errors.isNotEmpty()) { - throw ValidationException(errors) - } - } -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt deleted file mode 100644 index d0fc6fc..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.voir.formica.schema - -fun interface ValidationRule { - fun validate(value: T): FieldValidationResult -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt deleted file mode 100644 index efe40c5..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt +++ /dev/null @@ -1,350 +0,0 @@ -package dev.voir.formica.schema - -object ValidationRules { - fun validateOnlyIf(active: () -> Boolean, rule: ValidationRule) = - ValidationRule { v -> - if (active()) rule.validate( - v - ) else FieldValidationResult.Success - } - - fun required( - message: String = "Field is required", - isEmpty: (V?) -> Boolean = { v -> - when (v) { - null -> true - is String -> v.isBlank() - is Collection<*> -> v.isEmpty() - else -> false - } - } - ): ValidationRule = - ValidationRule { v -> - if (isEmpty(v)) { - FieldValidationResult.Error(message) - } else { - FieldValidationResult.Success - } - } - - /* TODO Maybe useful - fun requiredIf( - form: Formica, - predicate: (D) -> Boolean, - message: String - ): ValidationRule = ValidationRule { v -> - val active = predicate(form.data.value) // read live snapshot - if (!active) return@ValidationRule FieldResult.Success - - val empty = when (v) { - null -> true - is String -> v.isBlank() - is Collection<*> -> v.isEmpty() - else -> false - } - if (empty) FieldResult.Error(message) else FieldResult.Success - }*/ - - - fun notEmpty( - message: String = "This field cannot be empty." - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FieldValidationResult.Skip - v.isNotEmpty() -> FieldValidationResult.Success - else -> FieldValidationResult.Error(message) - } - } - - fun notBlank( - message: String = "This field cannot be blank." - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FieldValidationResult.Skip - v.isNotBlank() -> FieldValidationResult.Success - else -> FieldValidationResult.Error(message) - } - } - - fun email( - message: String = "Must be a valid email address.", - pattern: Regex = EMAIL_PATTERN.toRegex() - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FieldValidationResult.Skip - v.matches(pattern) -> FieldValidationResult.Success - else -> FieldValidationResult.Error(message) - } - } - - fun strongPassword( - minLength: Int = 8, - lengthMessage: String = "Password must be at least $minLength characters long.", - uppercaseMessage: String = "Password must contain at least one uppercase letter.", - lowercaseMessage: String = "Password must contain at least one lowercase letter.", - digitMessage: String = "Password must contain at least one digit.", - specialCharacterMessage: String = "Password must contain at least one special character.", - ): ValidationRule = - ValidationRule { v -> - when { - v == null -> FieldValidationResult.Skip - v.length < minLength -> FieldValidationResult.Error( - lengthMessage - ) - - !v.any { it.isUpperCase() } -> FieldValidationResult.Error( - uppercaseMessage - ) - - !v.any { it.isLowerCase() } -> FieldValidationResult.Error( - lowercaseMessage - ) - - !v.any { it.isDigit() } -> FieldValidationResult.Error( - digitMessage - ) - - !v.any { it in "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" } -> FieldValidationResult.Error( - specialCharacterMessage - ) - - else -> FieldValidationResult.Success - } - } - - fun url( - protocolRequired: Boolean = false, - message: String = "Must be a valid URL." - ): ValidationRule = - ValidationRule { v -> - if (v == null) { - return@ValidationRule FieldValidationResult.Skip - } - - val result = if (protocolRequired) { - v.matches(HTTP_URL_PATTERN.toRegex()) - } else { - v.matches(HTTP_URL_PATTERN.toRegex()) || v.matches(DOMAIN_URL_PATTERN.toRegex()) - } - - if (result) { - FieldValidationResult.Success - } else { - FieldValidationResult.Error(message) - } - } - - fun checked(message: String = "Must be checked"): ValidationRule = - ValidationRule { v -> - if (v == null) { - return@ValidationRule FieldValidationResult.Skip - } - - if (v) { - FieldValidationResult.Success - } else { - FieldValidationResult.Error(message) - } - } - - fun minLength( - option: Int, - message: String = "Must be at least $option characters long.", - ): ValidationRule = - ValidationRule { v -> - if (v == null) { - return@ValidationRule FieldValidationResult.Skip - } - if (v.count() >= option) { - FieldValidationResult.Success - } else { - FieldValidationResult.Error(message) - } - } - - fun maxLength( - option: Int, - message: String = "Must not exceed $option characters.", - ): ValidationRule = - ValidationRule { v -> - if (v == null) { - return@ValidationRule FieldValidationResult.Skip - } - if (v.count() <= option) { - FieldValidationResult.Success - } else { - FieldValidationResult.Error(message) - } - } - - fun range( - min: T, - max: T, - inclusive: Boolean = true, - message: (min: T, max: T) -> String = { lo, hi -> "Must be a number between $lo and $hi." } - ): ValidationRule where T : Number, T : Comparable = - ValidationRule { v -> - if (v == null) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v >= min && v <= max else v > min && v < max - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(min, max) - ) - } - - fun range( - min: Double, - max: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double, Double) -> String = { lo, hi -> "Must be a number between $lo and $hi." } - ): ValidationRule = - ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip - val lower = if (inclusive) v >= min - epsilon else v > min + epsilon - val upper = if (inclusive) v <= max + epsilon else v < max - epsilon - if (lower && upper) FieldValidationResult.Success else FieldValidationResult.Error( - message( - min, - max - ) - ) - } - - fun range( - min: Float, - max: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float, Float) -> String = { lo, hi -> "Must be a number between $lo and $hi." } - ): ValidationRule = - ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip - val lower = if (inclusive) v >= min - epsilon else v > min + epsilon - val upper = if (inclusive) v <= max + epsilon else v < max - epsilon - if (lower && upper) FieldValidationResult.Success else FieldValidationResult.Error( - message( - min, - max - ) - ) - } - - fun min( - min: Int, - inclusive: Boolean = true, - message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v >= min else v > min - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(min) - ) - } - - fun max( - max: Int, - inclusive: Boolean = true, - message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v <= max else v < max - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(max) - ) - } - - fun min( - min: Long, - inclusive: Boolean = true, - message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v >= min else v > min - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(min) - ) - } - - fun max( - max: Long, - inclusive: Boolean = true, - message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v <= max else v < max - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(max) - ) - } - - fun min( - min: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v >= min - epsilon else v > min + epsilon - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(min) - ) - } - - fun max( - max: Double, - inclusive: Boolean = true, - epsilon: Double = 0.0, - message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v <= max + epsilon else v < max - epsilon - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(max) - ) - } - - fun min( - min: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v >= min - epsilon else v > min + epsilon - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(min) - ) - } - - fun max( - max: Float, - inclusive: Boolean = true, - epsilon: Float = 0f, - message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } - ): ValidationRule = - ValidationRule { v -> - if (v == null || v.isNaN()) return@ValidationRule FieldValidationResult.Skip - val ok = if (inclusive) v <= max + epsilon else v < max - epsilon - if (ok) FieldValidationResult.Success else FieldValidationResult.Error( - message(max) - ) - } -} - -internal const val EMAIL_PATTERN = "[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + - "(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+" - -internal const val DOMAIN_URL_PATTERN = - "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" -internal const val HTTP_URL_PATTERN = - "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*\$" diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt deleted file mode 100644 index 4c6bfab..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaField.kt +++ /dev/null @@ -1,195 +0,0 @@ -package dev.voir.formica.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import dev.voir.formica.Formica -import dev.voir.formica.FormicaFieldId -import dev.voir.formica.FormicaFieldResult -import dev.voir.formica.ValidationRule -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map - -/** - * A stable snapshot of a form field's UI-facing state and operations. - * - * This is what you pass into your composable input components so they have: - * - [value] → the current field value (nullable to represent "empty") - * - [error] → the last validation error message, or null if valid - * - [touched] → whether the user has interacted with the field yet - * - [dirty] → whether the value has changed from its initial value - * - [onChange] → callback to update the value (also updates form data snapshot) - * - [validate] → function to trigger validation manually; returns true if valid - * - * Marked as @Stable so Compose can optimize recompositions when only internal - * values change. - */ -@Stable -data class FieldAdapter( - val value: V?, - val error: String?, - val touched: Boolean, - val dirty: Boolean, - val onChange: (V?) -> Unit, - val validate: () -> Boolean -) - - -/** - * Register a form field with the given [Formica] instance and expose it to UI - * via a [FieldAdapter] in a type-safe, reactive way. - * - * This overload is for when you have an explicit [form] reference. - * - * Typical usage in Compose: - * ``` - * FormicaField(form, FirstName, validators = setOf(...)) { f -> - * BasicTextField( - * value = f.value ?: "", - * onValueChange = { f.onChange(it) } - * ) - * f.error?.let { Text(it, color = Color.Red) } - * } - * ``` - * - * @param form The form instance holding all field state and data snapshot. - * @param id A [FormicaFieldId] lens for reading/writing this field in the form's data. - * @param validators Optional set of ordered validation rules for this field. - * @param customValidation Optional extra validation run after [validators]. - * @param validateOnChange Whether to run validation automatically on every value change. - * @param content Composable content lambda that receives a [FieldAdapter] for UI binding. - */ -@Composable -fun FormicaField( - form: Formica, - id: FormicaFieldId, - validators: Set> = emptySet(), - customValidation: ((V?) -> FormicaFieldResult)? = null, - validateOnChange: Boolean = true, - content: @Composable (FieldAdapter) -> Unit -) { - // Register the field once for this form + id combination. - // Registration seeds its initial value from the current form data snapshot. - val field = remember(form, id) { - form.registerField( - id = id, - validators = validators, - customValidation = customValidation, - validateOnChange = validateOnChange - ) - } - - // Collect reactive field state for UI binding. - // These flows update whenever field state changes (value, error, touched, dirty). - val value by field.value.collectAsState(initial = id.get(form.data.value)) - val error by field.error.collectAsState(initial = null) - val touched by field.touched.collectAsState(initial = false) - val dirty by field.dirty.collectAsState(initial = false) - - // Package the reactive state and callbacks into a stable adapter for the UI. - val adapter = remember(value, error, touched, dirty) { - FieldAdapter( - value = value, - error = error, - touched = touched, - dirty = dirty, - onChange = { v -> form.onChange(id, v) }, - validate = { field.isValid() } - ) - } - - // Render UI content with the adapter. - content(adapter) -} - -/** - * Overload of [FormicaField] that uses the ambient [LocalFormica] form context - * instead of requiring an explicit [form] parameter. - * - * Allows cleaner usage when you've wrapped your UI in a FormicaProvider: - * ``` - * FormicaProvider(form) { - * FormicaField(FirstName) { f -> - * BasicTextField( - * value = f.value ?: "", - * onValueChange = { f.onChange(it) } - * ) - * } - * } - * ``` - */ -@Composable -fun FormicaField( - id: FormicaFieldId, - validators: Set> = emptySet(), - customValidation: ((V?) -> FormicaFieldResult)? = null, - validateOnChange: Boolean = true, - content: @Composable (FieldAdapter) -> Unit -) { - val form = formicaOf() // Grab the current form from the composition - FormicaField( - form = form, - id = id, - validators = validators, - customValidation = customValidation, - validateOnChange = validateOnChange, - content = content - ) -} - -/** - * Convenience for reading a field's committed value from a [Formica] instance - * reactively (without registering a field). - * - * Returns the current value of the field from the immutable form data snapshot. - */ -@Composable -fun rememberFormicaFieldValue( - form: Formica, - id: FormicaFieldId, - // Optional comparator (useful for floats with epsilon) - areEquivalent: (V?, V?) -> Boolean = { a, b -> a == b } -): V? { - // If the field is registered, prefer its own StateFlow (cheapest & already scoped) - val registered = remember(form, id) { form.getRegisteredField(id) } - val initial = remember(form, id) { id.get(form.data.value) } - - return if (registered != null) { - registered.value.collectAsState(initial = initial).value - } else { - // Project the form snapshot to just this field and suppress identical emissions - val projected = remember(form, id, areEquivalent) { - form.data - .map { id.get(it) } - .distinctUntilChanged { old, new -> areEquivalent(old, new) } - } - projected.collectAsState(initial = initial).value - } -} - -@Composable -fun rememberFormicaFieldValue( - id: FormicaFieldId, - areEquivalent: (V?, V?) -> Boolean = { a, b -> a == b } -): V? { - val form = formicaOf() - return rememberFormicaFieldValue(form, id, areEquivalent) -} - -/** - * Overload of [rememberFormicaFieldValue] that uses [LocalFormica] instead of - * requiring an explicit [form] parameter. - * - * Useful inside a [FormicaProvider] scope: - * ``` - * val firstName = rememberFormicaFieldValue(FirstName) ?: "" - * ``` - */ -@Composable -fun rememberFormicaFieldValue(id: FormicaFieldId): V? { - val form = formicaOf() - val data by form.data.collectAsState() - return id.get(data) -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt deleted file mode 100644 index 2bb3fa9..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldPresence.kt +++ /dev/null @@ -1,76 +0,0 @@ -package dev.voir.formica.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import dev.voir.formica.Formica -import dev.voir.formica.FormicaFieldId - -/** - * Conditional wrapper for a form field that can be shown/hidden without losing registration. - * - * This is useful for *conditionally rendered fields* where: - * - You still want the field to be part of the form's registry (state preserved across shows/hides) - * - You want to enable/disable validation automatically based on visibility - * - You optionally want to clear its value when it is hidden - * - * ### How it works - * - Looks up the registered [FormicaField] for [id] (does **not** register it — you must have - * registered it beforehand via `FormicaField` or `registerField`). - * - Whenever [present] changes: - * - Calls `field.setEnabled(present)` so validation short-circuits when hidden. - * - If `present == false` and [clearOnHide] is `true`, also calls `form.onChange(id, null)` - * to clear the field value in both field state and the form data snapshot. - * - Renders [content] only if [present] is `true`. - * - * ### Example: - * ``` - * // Always register the field - * FormicaField(form, AdditionalText) { adapter -> - * TextField( - * value = adapter.value.orEmpty(), - * onValueChange = adapter.onChange - * ) - * } - * - * // Conditionally render it - * FormFieldPresence( - * form = form, - * id = AdditionalText, - * present = isExtraSectionEnabled, - * clearOnHide = true - * ) { - * // UI for AdditionalText here - * } - * ``` - * - * @param form The form instance holding the registered field. - * @param id The lens identifying the field to control. - * @param present Whether the field should be visible and validated. - * @param clearOnHide If true, clears the field value when it becomes hidden. - * @param content UI content to render when the field is present. - */ -@Composable -fun FormFieldPresence( - form: Formica, - id: FormicaFieldId, - present: Boolean, - clearOnHide: Boolean = false, - content: @Composable () -> Unit -) { - // Cache the registered field instance for the lifetime of this form+id combination. - // This avoids re-fetching the field every recomposition. - val field = remember(form, id) { form.getRegisteredField(id) } - - // React to changes in the `present` flag. - LaunchedEffect(present) { - field?.setEnabled(present) // Enable/disable validation - if (!present && clearOnHide) { - // Clear value in both field state and form data snapshot - form.onChange(id, null) - } - } - - // Render UI only when field is "present". - if (present) content() -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt deleted file mode 100644 index e1493b8..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaFieldState.kt +++ /dev/null @@ -1,106 +0,0 @@ -package dev.voir.formica.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import dev.voir.formica.Formica -import dev.voir.formica.FormicaFieldId - -/** - * A stable snapshot of a registered field's full reactive state and actions. - * - * This is very similar to [FieldAdapter], but intended for cases where you want - * to **access a field's state outside of its own Composable input**, for example: - * - * - To conditionally render another UI element based on the field's value or error - * - To build composite components that depend on multiple fields - * - To trigger validation from somewhere else in the UI - * - * @param value Current value of the field (nullable to represent "empty"). - * @param error Current error message if invalid, or `null` if valid. - * @param touched Whether the field has been interacted with at least once. - * @param dirty Whether the value has changed from its initial value. - * @param onChange Callback to update the field's value in both field state and form data. - * @param validate Triggers validation immediately; returns `true` if valid. - * - * Marked @Stable so Compose can optimize recomposition. - */ -@Stable -data class FormicaFieldState( - val value: V?, - val error: String?, - val touched: Boolean, - val dirty: Boolean, - val onChange: (V?) -> Unit, - val validate: () -> Boolean -) - -/** - * Retrieve a [FormicaFieldState] for a previously registered field in [form], - * observing all of its reactive properties (value, error, touched, dirty). - * - * @param form The [Formica] instance that holds the registered field. - * @param id The [FormicaFieldId] lens identifying the field. - * - * @return A [FormicaFieldState] bound to this field, or `null` if the field - * has not been registered in the form. - * - * ### Example - * ``` - * val fieldState = rememberFormicaFieldState(form, Email) - * if (fieldState?.error != null) { - * Text("Invalid email", color = Color.Red) - * } - * ``` - * - * **Important:** This does *not* register the field. You must register it - * beforehand with `FormicaField(...)` or `form.registerField(...)`. - */ -@Composable -fun rememberFormicaFieldState( - form: Formica, - id: FormicaFieldId -): FormicaFieldState? { - // Remember the registered field instance so we don't look it up every recomposition - val field = remember(form, id) { form.getRegisteredField(id) } ?: return null - - // Observe reactive properties from the field's StateFlows - val value by field.value.collectAsState() - val error by field.error.collectAsState() - val touched by field.touched.collectAsState() - val dirty by field.dirty.collectAsState() - - // Package everything into a stable snapshot object - return remember(value, error, touched, dirty) { - FormicaFieldState( - value = value, - error = error, - touched = touched, - dirty = dirty, - onChange = { form.onChange(id, it) }, - validate = { field.isValid() } - ) - } -} - -/** - * Overload of [rememberFormicaFieldState] that retrieves the current [Formica] - * instance from [LocalFormica], so you don't need to pass `form` manually. - * - * Use inside a [FormicaProvider] scope: - * ``` - * val fieldState = rememberFormicaFieldState(Email) - * if (fieldState?.dirty == true) { - * SaveButton() - * } - * ``` - */ -@Composable -fun rememberFormicaFieldState( - id: FormicaFieldId -): FormicaFieldState? { - val form = formicaOf() - return rememberFormicaFieldState(form, id) -} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt b/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt deleted file mode 100644 index 00dcc98..0000000 --- a/formica/src/commonMain/kotlin/dev/voir/formica/ui/FormicaProvider.kt +++ /dev/null @@ -1,99 +0,0 @@ -package dev.voir.formica.ui - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.runtime.staticCompositionLocalOf -import dev.voir.formica.Formica - -/** - * A CompositionLocal that holds the current [Formica] instance for this UI tree. - * - * - Typed as `Formica?` internally so it can store any generic form type. - * - Accessed via [formicaOf] to get a strongly-typed instance. - * - Provided by [FormicaProvider]. - * - * Defaults to `null` when not inside a [FormicaProvider] scope. - */ -val LocalFormica = staticCompositionLocalOf?> { null } - -/** - * Create and remember a [Formica] instance tied to the current composition. - * - * This is typically called at the top of a screen or form scope. - * The instance will survive recompositions, and will only be recreated when - * either [initialData] or [onSubmit] changes. - * - * @param initialData The initial immutable data model for the form. - * @param onSubmit Optional callback invoked with the form's current data when [Formica.submit] is called. - * - * @return A remembered [Formica] instance you can use directly or pass to [FormicaProvider]. - * - * ### Example: - * ``` - * val form = rememberFormica(Profile("", null)) { data -> - * saveProfile(data) - * } - * ``` - */ -@Composable -fun rememberFormica( - initialData: Data, - onSubmit: ((Data) -> Unit)? = null -): Formica { - // Will only recreate the Formica instance when initialData or onSubmit changes - return remember(initialData, onSubmit) { - Formica(initialData, onSubmit) - } -} - -/** - * Provide a [Formica] instance to all composables in [content] via [LocalFormica]. - * - * This allows child composables to use [formicaOf] to retrieve the form without - * having to pass it down explicitly. - * - * @param form The form instance to provide in this composition scope. - * @param content UI content that should have access to [form] via [LocalFormica]. - * - * ### Example: - * ``` - * val form = rememberFormica(Profile("", null)) - * FormicaProvider(form) { - * // Inside here, you can call formicaOf() to get the form - * } - * ``` - */ -@Composable -fun FormicaProvider( - form: Formica, - content: @Composable () -> Unit -) { - @Suppress("UNCHECKED_CAST") // Safe cast because we only ever read via formicaOf() - CompositionLocalProvider(LocalFormica provides (form as Formica)) { - content() - } -} - -/** - * Retrieve the current [Formica] instance from [LocalFormica] with strong typing. - * - * This must be called inside a [FormicaProvider] scope, otherwise it will throw an error. - * - * @return The current [Formica] instance typed as [Formica]. - * - * ### Example: - * ``` - * val form = formicaOf() - * form.onChange(FirstName, "Gary") - * ``` - * - * @throws IllegalStateException if no [FormicaProvider] is found in the current composition. - */ -@Suppress("UNCHECKED_CAST") -@Composable -fun formicaOf(): Formica { - val f = LocalFormica.current - ?: error("No Formica in scope. Wrap with FormicaProvider or pass form explicitly.") - return f as Formica -} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt deleted file mode 100644 index 8c9ac60..0000000 --- a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaFieldTest.kt +++ /dev/null @@ -1,224 +0,0 @@ -package dev.voir.formica - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class FormicaFieldTest { - - // --- helpers ------------------------------------------------------------- - - private fun success() = FormicaFieldResult.Success - private fun err(msg: String) = FormicaFieldResult.Error(msg) - - private fun rule(block: (V?) -> FormicaFieldResult): ValidationRule = - ValidationRule { v -> block(v) } - - // Kotlin's setOf(...) is insertion-ordered (LinkedHashSet), so we can verify short-circuit. - private fun orderedRules(vararg rules: ValidationRule): Set> = - linkedSetOf(*rules) - - // --- initial state ------------------------------------------------------- - - @Test - fun initial_state_is_pristine() { - val f = FormicaField( - initialValue = "foo" - ) - - assertEquals("foo", f.value.value) - assertNull(f.error.value) - assertTrue(f.result.value is FormicaFieldResult.NoInput) - assertFalse(f.touched.value) - assertFalse(f.dirty.value) - } - - // --- onChange / touched / dirty ----------------------------------------- - - @Test - fun onChange_sets_value_and_flags() { - val f = FormicaField(initialValue = "a", validateOnChange = false) - - f.onChange("a") // same as initial - assertEquals("a", f.value.value) - assertTrue(f.touched.value) - assertFalse(f.dirty.value) - - f.onChange("b") // different - assertEquals("b", f.value.value) - assertTrue(f.touched.value) - assertTrue(f.dirty.value) - } - - // --- validateOnChange behaviour ----------------------------------------- - - @Test - fun validateOnChange_false_does_not_validate_automatically() { - val f = FormicaField( - initialValue = "", - validators = orderedRules(rule { if (it.isNullOrBlank()) err("x") else success() }), - validateOnChange = false - ) - - // After change, still NoInput because we didn't call validate() - f.onChange("") - assertTrue(f.result.value is FormicaFieldResult.NoInput) - assertNull(f.error.value) - - // Now validate explicitly - val r = f.validate() - assertTrue(r is FormicaFieldResult.Error) - assertEquals("x", f.error.value) - } - - @Test - fun validateOnChange_true_validates_automatically() { - val f = FormicaField( - initialValue = "", - validators = orderedRules(rule { if (it.isNullOrBlank()) err("empty") else success() }), - validateOnChange = true - ) - - f.onChange("") // triggers validate - assertEquals("empty", f.error.value) - assertTrue(f.result.value is FormicaFieldResult.Error) - - f.onChange("ok") // triggers validate - assertNull(f.error.value) - assertTrue(f.result.value is FormicaFieldResult.Success) - } - - // --- validators before custom & short-circuit ---------------------------- - - @Test - fun validators_run_before_custom_and_shortCircuit_on_error() { - var customCalled = false - - val v1 = rule { err("fail-1") } // first fails - val v2 = rule { error("should not run") } // must never run - val custom = { _: String? -> - customCalled = true - success() - } - - val f = FormicaField( - initialValue = "x", - validators = orderedRules(v1, v2), - customValidation = custom, - validateOnChange = false - ) - - val r = f.validate() - assertTrue(r is FormicaFieldResult.Error) - assertEquals("fail-1", f.error.value) - assertFalse(customCalled) // custom not called due to short-circuit - } - - @Test - fun custom_runs_when_validators_pass_and_can_fail() { - var vCalled = false - val vOk = rule { vCalled = true; success() } - val custom = { _: String? -> err("custom-fail") } - - val f = FormicaField( - initialValue = "x", - validators = orderedRules(vOk), - customValidation = custom, - validateOnChange = false - ) - - val r = f.validate() - assertTrue(vCalled) - assertTrue(r is FormicaFieldResult.Error) - assertEquals("custom-fail", f.error.value) - } - - @Test - fun success_sets_success_and_clears_error() { - val f = FormicaField( - initialValue = "x", - validators = orderedRules(rule { success() }), - customValidation = { success() }, - validateOnChange = false - ) - - val r = f.validate() - assertTrue(r is FormicaFieldResult.Success) - assertNull(f.error.value) - assertTrue(f.result.value is FormicaFieldResult.Success) - } - - // --- enabled / presence -------------------------------------------------- - - @Test - fun disabled_field_skips_validation_and_is_always_success() { - var vCalls = 0 - val v = rule { vCalls++; err("nope") } - - val f = FormicaField( - initialValue = "", - validators = orderedRules(v), - validateOnChange = true - ) - - // disable first - f.setEnabled(false) - - // onChange would normally validate, but since disabled, it should stay Success - f.onChange("") // validateOnChange triggers validate() - assertNull(f.error.value) - assertTrue(f.result.value is FormicaFieldResult.Success) - assertEquals(0, vCalls, "validator must not be called when disabled") - - // validate() should also short-circuit - val r = f.validate() - assertTrue(r is FormicaFieldResult.Success) - assertEquals(0, vCalls) - } - - // --- reset --------------------------------------------------------------- - - @Test - fun reset_restores_pristine_state_and_updates_initial() { - val f = FormicaField( - initialValue = "init", - validators = orderedRules(rule { if (it.isNullOrBlank()) err("empty") else success() }), - validateOnChange = true - ) - - f.onChange("") // set error, touched, dirty - assertTrue(f.touched.value) - assertTrue(f.dirty.value) - assertEquals("empty", f.error.value) - - f.reset("fresh") - assertEquals("fresh", f.value.value) - assertTrue(f.result.value is FormicaFieldResult.NoInput) - assertNull(f.error.value) - assertFalse(f.touched.value) - assertFalse(f.dirty.value) - - // dirty should reflect new initial - f.onChange("fresh") - assertFalse(f.dirty.value) - f.onChange("changed") - assertTrue(f.dirty.value) - } - - // --- isValid() ----------------------------------------------------------- - - @Test - fun isValid_calls_validate_and_returns_boolean() { - val f = FormicaField( - initialValue = "", - validators = orderedRules(rule { if (it.isNullOrBlank()) err("x") else success() }), - validateOnChange = false - ) - - assertFalse(f.isValid()) // triggers validate -> error - f.onChange("ok") - assertTrue(f.isValid()) - } -} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt deleted file mode 100644 index 171a4e8..0000000 --- a/formica/src/commonTest/kotlin/dev/voir/formica/FormicaTest.kt +++ /dev/null @@ -1,238 +0,0 @@ -package dev.voir.formica - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNotSame -import kotlin.test.assertNull -import kotlin.test.assertSame -import kotlin.test.assertTrue - -// ---------- Fixtures --------------------------------------------------------- - -private data class Profile( - val firstName: String, - val note: String?, - val age: Int -) - -private val FirstName = FormicaFieldId( - id = "firstName", - get = { it.firstName }, - set = { d, v -> d.copy(firstName = v) } -) - -private val Note = FormicaFieldId( - id = "note", - get = { it.note }, - set = { d, v -> d.copy(note = v) }, - clear = { d -> d.copy(note = null) } -) - -private val Age = FormicaFieldId( - id = "age", - get = { it.age }, - set = { d, v -> d.copy(age = v) } - // no clear -> null updates should NOT change data snapshot -) - -// Helpers -private fun rule(block: (V?) -> FormicaFieldResult): ValidationRule = - ValidationRule { v -> block(v) } - -// Preserve validator order explicitly for Set -private fun ordered(vararg r: ValidationRule): Set> = linkedSetOf(*r) - -// ---------- Tests ------------------------------------------------------------ - -class FormicaCoreTest { - - @Test - fun registerField_seeds_initialValue_from_data() { - val form = Formica(Profile("Ann", null, 21)) - val f = form.registerField( - id = FirstName, - validators = emptySet() - ) - assertEquals("Ann", f.value.value) - assertTrue(f.result.value is FormicaFieldResult.NoInput) - } - - @Test - fun onChange_updates_field_and_data_immutably() { - val form = Formica(Profile("Ann", null, 21)) - form.registerField(id = FirstName, validators = emptySet()) - - val before = form.data.value - form.onChange(FirstName, "Bob") - - val after = form.data.value - assertNotSame(before, after) // new snapshot - assertEquals("Bob", after.firstName) // changed - assertEquals("Ann", before.firstName) // old unchanged - } - - @Test - fun onChange_null_uses_clear_when_available() { - val form = Formica(Profile("Ann", "hello", 21)) - val field = form.registerField(id = Note, validators = emptySet()) - - // sanity: both data and field start with "hello" - assertEquals("hello", form.data.value.note) - assertEquals("hello", field.value.value) - - form.onChange(Note, null) // should call FormicaFieldId.clear - assertNull(form.data.value.note) // data cleared - assertNull(field.value.value) // field value updated - } - - @Test - fun onChange_null_without_clear_does_not_mutate_data_snapshot() { - val form = Formica(Profile("Ann", "x", 30)) - val ageField = form.registerField(id = Age, validators = emptySet()) - - val before = form.data.value - assertEquals(30, before.age) - - form.onChange(Age, null) // no clear provided - - val after = form.data.value - assertSame(before, after) // same instance -> no change - assertEquals(30, after.age) - assertNull(ageField.value.value) // field state still updated to null - } - - @Test - fun getRegisteredField_returns_same_instance_and_can_toggle_enabled() { - val form = Formica(Profile("Ann", null, 21)) - form.registerField( - id = FirstName, - validators = ordered(rule { FormicaFieldResult.Error("fail") }), - validateOnChange = true - ) - - val f = form.getRegisteredField(FirstName) - assertNotNull(f) - // Disable -> validation should short-circuit to Success - f.setEnabled(false) - form.onChange(FirstName, "") // would fail if enabled - assertTrue(f.result.value is FormicaFieldResult.Success) - assertNull(f.error.value) - } - - @Test - fun validate_aggregates_errors_with_fieldIds() { - val form = Formica(Profile("Ann", "", 5)) - form.registerField( - id = FirstName, - validators = ordered(rule { if (it.isNullOrBlank()) FormicaFieldResult.Error("first required") else FormicaFieldResult.Success }) - ) - form.registerField( - id = Note, - validators = ordered(rule { FormicaFieldResult.Success }) // note ok - ) - - // Make firstName empty -> should produce an error map entry - form.onChange(FirstName, "") - val res = form.validate() - assertTrue(res is FormicaResult.Error) - val err = res as FormicaResult.Error - assertEquals(mapOf("firstName" to "first required"), err.fieldErrors) - } - - @Test - fun validator_order_shortCircuits_and_custom_runs_last() { - var v1Called = false - var v2Called = false - var customCalled = false - - val form = Formica(Profile("Ann", null, 21)) - form.registerField( - id = FirstName, - validators = ordered( - rule { v1Called = true; FormicaFieldResult.Error("v1") }, - rule { v2Called = true; error("should not be called") } - ), - customValidation = { customCalled = true; FormicaFieldResult.Success }, - validateOnChange = false - ) - - val res = form.validate() - assertTrue(res is FormicaResult.Error) - assertTrue(v1Called) - assertFalse(v2Called) // short-circuited - assertFalse(customCalled) // skipped because validator failed - } - - @Test - fun syncFromData_resets_fields_to_match_current_snapshot() { - val form = Formica(Profile("Ann", "hello", 21)) - val noteField = - form.registerField(id = Note, validators = emptySet(), validateOnChange = true) - - // Mutate field (and make it dirty) - form.onChange(Note, "changed") - assertEquals("changed", noteField.value.value) - assertTrue(noteField.dirty.value) - - // Mutate DATA without touching field state via clear() - form.clear(Note) - assertNull(form.data.value.note) - // Field value is still "changed" and dirty at this moment - assertEquals("changed", noteField.value.value) - - // Now sync field state from DATA snapshot - form.syncFromData() - assertNull(noteField.value.value) - assertTrue(noteField.result.value is FormicaFieldResult.NoInput) - assertFalse(noteField.dirty.value) - assertFalse(noteField.touched.value) - } - - @Test - fun submit_invokes_onSubmit_only_when_valid() { - var submitted: Profile? = null - val form = Formica( - initialData = Profile("Ann", null, 21), - onSubmit = { submitted = it } - ) - - // FirstName required - form.registerField( - id = FirstName, - validators = ordered(rule { if (it.isNullOrBlank()) FormicaFieldResult.Error("required") else FormicaFieldResult.Success }) - ) - - // Make invalid - form.onChange(FirstName, "") - val r1 = form.submit() - assertTrue(r1 is FormicaResult.Error) - assertNull(submitted) - - // Fix and submit again - form.onChange(FirstName, "Ok") - val r2 = form.submit() - assertTrue(r2 is FormicaResult.Valid) - assertNotNull(submitted) - assertEquals("Ok", submitted!!.firstName) - } - - @Test - fun clear_updates_data_only_and_leaves_field_state_as_is() { - val form = Formica(Profile("Ann", "keep", 21)) - val noteField = - form.registerField(id = Note, validators = emptySet(), validateOnChange = false) - - // Change field & data to "text" - form.onChange(Note, "text") - assertEquals("text", noteField.value.value) - assertEquals("text", form.data.value.note) - - // Clear via form.clear -> data changes, field state remains "text" - form.clear(Note) - assertNull(form.data.value.note) - assertEquals("text", noteField.value.value) // unchanged! - // Caller can optionally call noteField.reset(...) or form.syncFromData() - } -} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRulesTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRulesTest.kt deleted file mode 100644 index b6b1ea7..0000000 --- a/formica/src/commonTest/kotlin/dev/voir/formica/ValidationRulesTest.kt +++ /dev/null @@ -1,305 +0,0 @@ -package dev.voir.formica - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class ValidationRulesTest { - - // ---- Helpers ------------------------------------------------------------ - - private fun assertSuccess(result: FormicaFieldResult) = - assertTrue(result is FormicaFieldResult.Success, "Expected Success, got $result") - - private fun assertNoInput(r: FormicaFieldResult) = - assertTrue(r is FormicaFieldResult.NoInput, "Expected NoInput, got $r") - - private fun assertErrorMessage(result: FormicaFieldResult, expected: String) { - when (result) { - is FormicaFieldResult.Error -> assertEquals(expected, result.message) - else -> throw AssertionError("Expected Error('$expected'), got $result") - } - } - - // ---- validateOnlyIf ----------------------------------------------------- - - @Test - fun validateOnlyIf_runsRuleWhenActive() { - var called = false - val rule = ValidationRules.validateOnlyIf(active = { true }) { v: String? -> - called = true - if (v.isNullOrBlank()) FormicaFieldResult.Error("X") else FormicaFieldResult.Success - } - - val r1 = rule.validate(null) - assertTrue(called) - assertErrorMessage(r1, "X") - - called = false - val r2 = rule.validate("ok") - assertTrue(called) - assertSuccess(r2) - } - - @Test - fun validateOnlyIf_skipsWhenInactive() { - var called = false - val rule = ValidationRules.validateOnlyIf(active = { false }) { _: String? -> - called = true - FormicaFieldResult.Error("should never be called") - } - - val r = rule.validate(null) - assertFalse(called) - assertSuccess(r) - } - - // ---- required ----------------------------------------------------------- - - @Test - fun required_handlesNullBlankAndEmpty() { - val ruleStr = ValidationRules.required() - assertErrorMessage(ruleStr.validate(null), "Field is required") - assertErrorMessage(ruleStr.validate(""), "Field is required") - assertErrorMessage(ruleStr.validate(" "), "Field is required") - assertSuccess(ruleStr.validate("data")) - - val ruleList = ValidationRules.required>() - assertErrorMessage(ruleList.validate(null), "Field is required") - assertErrorMessage(ruleList.validate(emptyList()), "Field is required") - assertSuccess(ruleList.validate(listOf(1))) - } - - @Test - fun required_customMessageAndIsEmpty() { - val rule = ValidationRules.required( - message = "Need a positive int", - isEmpty = { it == null || it!! <= 0 } - ) - assertErrorMessage(rule.validate(null), "Need a positive int") - assertErrorMessage(rule.validate(0), "Need a positive int") - assertSuccess(rule.validate(1)) - } - - // ---- notEmpty / notBlank ------------------------------------------------ - - @Test - fun notEmpty_works() { - val rule = ValidationRules.notEmpty("NE") - assertErrorMessage(rule.validate(""), "NE") - assertSuccess(rule.validate(" ")) - assertSuccess(rule.validate("x")) - } - - @Test - fun notBlank_works() { - val rule = ValidationRules.notBlank("NB") - assertErrorMessage(rule.validate(""), "NB") - assertErrorMessage(rule.validate(" "), "NB") - assertSuccess(rule.validate("x")) - assertSuccess(rule.validate(" x ")) - } - - // ---- email -------------------------------------------------------------- - - @Test - fun email_validAndInvalid() { - val rule = ValidationRules.email("Bad email") - - // Valid - assertSuccess(rule.validate("a@b.co")) - assertSuccess(rule.validate("first.last+tag@sub.domain.io")) - - // Invalid - assertErrorMessage(rule.validate("plainaddress"), "Bad email") - assertErrorMessage(rule.validate("a@b"), "Bad email") - assertErrorMessage(rule.validate("@nope.com"), "Bad email") - assertErrorMessage(rule.validate("a@b..com"), "Bad email") - } - - // ---- strongPassword ----------------------------------------------------- - - @Test - fun strongPassword_coversEachFailurePath() { - val rule = ValidationRules.strongPassword( - minLength = 8, - lengthMessage = "LEN", - uppercaseMessage = "UC", - lowercaseMessage = "LC", - digitMessage = "DG", - specialCharacterMessage = "SC" - ) - - assertErrorMessage(rule.validate("A1!a"), "LEN") // too short - assertErrorMessage(rule.validate("abcd123!"), "UC") // no uppercase - assertErrorMessage(rule.validate("ABCD123!"), "LC") // no lowercase - assertErrorMessage(rule.validate("Abcd!!!!"), "DG") // no digit - assertErrorMessage(rule.validate("Abcd1234"), "SC") // no special - - assertSuccess(rule.validate("Abcd123!")) // all good - } - - // ---- url ---------------------------------------------------------------- - - @Test - fun url_withOrWithoutProtocol() { - val anyUrl = ValidationRules.url(protocolRequired = false, message = "URL") - val protoOnly = ValidationRules.url(protocolRequired = true, message = "URL") - - // Valid without protocol - assertSuccess(anyUrl.validate("example.com")) - assertSuccess(anyUrl.validate("sub.domain.io/path?q=1")) - - // Valid with protocol - assertSuccess(anyUrl.validate("http://example.com")) - assertSuccess(anyUrl.validate("https://www.example.org/a/b?x=1#frag")) - assertSuccess(protoOnly.validate("https://example.com")) - - // Invalids - assertErrorMessage(protoOnly.validate("example.com"), "URL") - assertErrorMessage(anyUrl.validate("htp://broken.com"), "URL") - assertErrorMessage(anyUrl.validate("://nope.com"), "URL") - assertErrorMessage(anyUrl.validate(" "), "URL") - } - - // ---- checked ------------------------------------------------------------ - - @Test - fun checked_rule() { - val rule = ValidationRules.checked("Must be checked") - assertErrorMessage(rule.validate(false), "Must be checked") - assertSuccess(rule.validate(true)) - } - - // ---- minLength / maxLength --------------------------------------------- - - @Test - fun minLength_and_maxLength() { - val min3 = ValidationRules.minLength(3, message = "MIN3") - val max5 = ValidationRules.maxLength(5, message = "MAX5") - - assertErrorMessage(min3.validate("ab"), "MIN3") - assertSuccess(min3.validate("abc")) - assertSuccess(min3.validate("abcdef")) - - assertSuccess(max5.validate("abc")) - assertSuccess(max5.validate("abcde")) - assertErrorMessage(max5.validate("abcdef"), "MAX5") - } - - // ---- range --------------------------------------- - - @Test - fun range_int_inclusive() { - val r = ValidationRules.range(min = 2, max = 4, message = { min, max -> - "RANGE MIN $min AND MAX $max" - }) - assertErrorMessage(r.validate(1), "RANGE MIN 2 AND MAX 4") - assertSuccess(r.validate(2)) // min boundary - assertSuccess(r.validate(3)) - assertSuccess(r.validate(4)) // max boundary - assertErrorMessage(r.validate(5), "RANGE MIN 2 AND MAX 4") - } - - @Test - fun range_float_inclusive() { - val r = ValidationRules.range(min = 0.5f, max = 1.5f, message = { min, max -> - "RANGE MIN $min AND MAX $max" - }) - assertErrorMessage(r.validate(0.49f), "RANGE MIN 0.5 AND MAX 1.5") - assertSuccess(r.validate(0.5f)) - assertSuccess(r.validate(1.0f)) - assertSuccess(r.validate(1.5f)) - assertErrorMessage(r.validate(1.5001f), "RANGE MIN 0.5 AND MAX 1.5") - } - - - @Test - fun int_min_inclusive() { - val rule = ValidationRules.min(5) // inclusive by default - assertNoInput(rule.validate(null)) - assertErrorMessage(rule.validate(4), "Must be >= 5.") - assertSuccess(rule.validate(5)) - assertSuccess(rule.validate(6)) - } - - @Test - fun int_min_exclusive() { - val rule = ValidationRules.min(5, inclusive = false) - assertNoInput(rule.validate(null)) - assertErrorMessage(rule.validate(5), "Must be > 5.") - assertSuccess(rule.validate(6)) - } - - @Test - fun int_max_inclusive() { - val rule = ValidationRules.max(10) // inclusive by default - assertNoInput(rule.validate(null)) - assertSuccess(rule.validate(9)) - assertSuccess(rule.validate(10)) - assertErrorMessage(rule.validate(11), "Must be <= 10.") - } - - @Test - fun int_max_exclusive() { - val rule = ValidationRules.max(10, inclusive = false) - assertNoInput(rule.validate(null)) - assertSuccess(rule.validate(9)) - assertErrorMessage(rule.validate(10), "Must be < 10.") - } - - @Test - fun float_min_inclusive_noEpsilon() { - val rule = ValidationRules.min(1.5f) // inclusive, epsilon = 0f - assertNoInput(rule.validate(null)) - assertNoInput(rule.validate(Float.NaN)) - assertErrorMessage(rule.validate(1.4f), "Must be >= 1.5.") - assertSuccess(rule.validate(1.5f)) - assertSuccess(rule.validate(1.5000001f)) - } - - @Test - fun float_min_exclusive_noEpsilon() { - val rule = ValidationRules.min(1.5f, inclusive = false) - assertNoInput(rule.validate(null)) - assertNoInput(rule.validate(Float.NaN)) - assertErrorMessage(rule.validate(1.5f), "Must be > 1.5.") - assertSuccess(rule.validate(1.500001f)) - } - - @Test - fun float_min_inclusive_withEpsilon() { - val rule = ValidationRules.min(min = 1.5f, inclusive = true, epsilon = 0.01f) - // With epsilon, values in [min - eps, +∞) are accepted (inclusive branch) - assertSuccess(rule.validate(1.491f)) // >= 1.5 - 0.01 = 1.49 → OK - assertErrorMessage(rule.validate(1.489f), "Must be >= 1.5.") - } - - @Test - fun float_max_inclusive_noEpsilon() { - val rule = ValidationRules.max(2.5f) // inclusive, epsilon = 0f - assertNoInput(rule.validate(null)) - assertNoInput(rule.validate(Float.NaN)) - assertSuccess(rule.validate(2.4f)) - assertSuccess(rule.validate(2.5f)) - assertErrorMessage(rule.validate(2.6f), "Must be <= 2.5.") - } - - @Test - fun float_max_exclusive_noEpsilon() { - val rule = ValidationRules.max(2.5f, inclusive = false) - assertNoInput(rule.validate(null)) - assertNoInput(rule.validate(Float.NaN)) - assertSuccess(rule.validate(2.4999f)) - assertErrorMessage(rule.validate(2.5f), "Must be < 2.5.") - } - - @Test - fun float_max_inclusive_withEpsilon() { - val rule = ValidationRules.max(max = 2.5f, inclusive = true, epsilon = 0.01f) - // With epsilon, values in (-∞, max + eps] are accepted (inclusive branch) - assertSuccess(rule.validate(2.509f)) // <= 2.5 + 0.01 = 2.51 → OK - assertErrorMessage(rule.validate(2.511f), "Must be <= 2.5.") - } -} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/core/FormicaTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/core/FormicaTest.kt new file mode 100644 index 0000000..12328dc --- /dev/null +++ b/formica/src/commonTest/kotlin/dev/voir/formica/core/FormicaTest.kt @@ -0,0 +1,529 @@ +package dev.voir.formica.core + +import kotlin.test.* + +class FormicaTest { + + data class UserForm( + val email: String = "", + val name: String = "", + val age: Int? = null, + val active: Boolean = true, + val note: String? = null + ) + + private class TestAdapter( + override val fields: List> + ) : FormicaValidationAdapter { + + override fun validate(data: UserForm): FormicaValidationResult { + val errors = buildMap { + if (data.email.isBlank()) { + put("email", "Email is required") + } else if (!data.email.contains("@")) { + put("email", "Email is invalid") + } + + if (data.name.isBlank()) { + put("name", "Name is required") + } + + if (data.age != null && data.age < 18) { + put("age", "Must be at least 18") + } + + if (data.note != null && data.note.isEmpty()) { + put("note", "Note cannot be empty if provided") + } + } + + return FormicaValidationResult( + data = data, + fieldErrors = errors, + formErrors = if (data.email == "global@error.com") { + listOf("Global form error") + } else { + emptyList() + } + ) + } + + override fun validateField( + data: UserForm, + fieldKey: String + ): FormicaFieldValidationResult { + return when (fieldKey) { + "email" -> when { + data.email.isBlank() -> FormicaFieldValidationResult.Error("Email is required") + !data.email.contains("@") -> FormicaFieldValidationResult.Error("Email is invalid") + else -> FormicaFieldValidationResult.Success + } + + "name" -> if (data.name.isBlank()) { + FormicaFieldValidationResult.Error("Name is required") + } else { + FormicaFieldValidationResult.Success + } + + "age" -> if (data.age != null && data.age < 18) { + FormicaFieldValidationResult.Error("Must be at least 18") + } else { + FormicaFieldValidationResult.Success + } + + "note" -> if (data.note != null && data.note.isEmpty()) { + FormicaFieldValidationResult.Error("Note cannot be empty if provided") + } else { + FormicaFieldValidationResult.Success + } + + else -> FormicaFieldValidationResult.Skip + } + } + } + + private fun adapter( + emailValidateOnChange: Boolean = true + ): TestAdapter { + val emailField = DefaultFormicaFieldDefinition( + key = "email", + property = UserForm::email, + set = { data, value -> data.copy(email = value ?: "") }, + clear = { data -> data.copy(email = "") }, + validateOnChange = emailValidateOnChange + ) + + val nameField = DefaultFormicaFieldDefinition( + key = "name", + property = UserForm::name, + set = { data, value -> data.copy(name = value ?: "") }, + clear = { data -> data.copy(name = "") } + ) + + val ageField = DefaultFormicaFieldDefinition( + key = "age", + property = UserForm::age, + set = { data, value -> data.copy(age = value) }, + clear = { data -> data.copy(age = null) } + ) + + val activeField = DefaultFormicaFieldDefinition( + key = "active", + property = UserForm::active, + set = { data, value -> data.copy(active = value ?: false) }, + clear = { data -> data.copy(active = false) } + ) + + val noteField = DefaultFormicaFieldDefinition( + key = "note", + property = UserForm::note, + set = { data, value -> data.copy(note = value) }, + clear = { data -> data.copy(note = null) }, + isVisible = { it.active }, + isEnabled = { it.active } + ) + + return TestAdapter( + fields = listOf(emailField, nameField, ageField, activeField, noteField) + ) + } + + @Test + fun `initial state is pristine and uses initial data`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm( + email = "a@b.com", + name = "John", + age = 20, + active = true, + note = "hi" + ) + ) + + assertEquals("a@b.com", formica.data.value.email) + assertEquals("John", formica.data.value.name) + assertFalse(formica.isDirty.value) + assertFalse(formica.isTouched.value) + assertFalse(formica.hasErrors.value) + assertFalse(formica.canSubmit.value) + + val emailField = formica.fieldState(UserForm::email) + assertEquals("a@b.com", emailField.value.value) + assertNull(emailField.error.value) + assertFalse(emailField.dirty.value) + assertFalse(emailField.touched.value) + assertTrue(emailField.visible.value) + assertTrue(emailField.enabled.value) + } + + @Test + fun `onChange updates immutable data and field state`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm() + ) + + formica.onChange(UserForm::email, "john@example.com") + + assertEquals("john@example.com", formica.data.value.email) + assertTrue(formica.isDirty.value) + assertTrue(formica.isTouched.value) + + val emailField = formica.fieldState(UserForm::email) + assertEquals("john@example.com", emailField.value.value) + assertTrue(emailField.dirty.value) + assertTrue(emailField.touched.value) + } + + @Test + fun `validateField stores field error`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "", name = "John") + ) + + val result = formica.validateField(UserForm::email) + + assertIs(result) + assertEquals("Email is required", result.message) + + val emailField = formica.fieldState(UserForm::email) + assertEquals("Email is required", emailField.error.value) + assertTrue(formica.hasErrors.value) + } + + @Test + fun `validateField stores success for valid field`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "john@example.com", name = "John") + ) + + val result = formica.validateField(UserForm::email) + + assertIs(result) + assertNull(formica.fieldState(UserForm::email).error.value) + assertFalse(formica.hasErrors.value) + } + + @Test + fun `validate validates all visible enabled fields`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "bad", name = "", age = 12, active = true, note = "") + ) + + val result = formica.validate() + + assertFalse(result.isValid) + assertEquals("Email is invalid", result.fieldErrors["email"]) + assertEquals("Name is required", result.fieldErrors["name"]) + assertEquals("Must be at least 18", result.fieldErrors["age"]) + assertEquals("Note cannot be empty if provided", result.fieldErrors["note"]) + + assertEquals(result.fieldErrors, formica.fieldErrors.value) + assertIs(formica.submitResult.value) + assertTrue(formica.hasErrors.value) + } + + @Test + fun `validate succeeds when data is valid`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm( + email = "john@example.com", + name = "John", + age = 30, + active = true, + note = "ok" + ) + ) + + val result = formica.validate() + + assertTrue(result.isValid) + assertTrue(formica.fieldErrors.value.isEmpty()) + assertTrue(formica.formErrors.value.isEmpty()) + assertIs(formica.submitResult.value) + assertFalse(formica.hasErrors.value) + } + + @Test + fun `submit calls callback only when validation succeeds`() { + var submitted: UserForm? = null + + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "john@example.com", name = "John"), + onSubmit = { submitted = it } + ) + + val result = formica.submit() + + assertTrue(result.isValid) + assertEquals(UserForm(email = "john@example.com", name = "John"), submitted) + } + + @Test + fun `submit does not call callback when validation fails`() { + var submitted: UserForm? = null + + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "", name = ""), + onSubmit = { submitted = it } + ) + + val result = formica.submit() + + assertFalse(result.isValid) + assertNull(submitted) + assertIs(formica.submitResult.value) + } + + @Test + fun `reset restores pristine state and clears validation`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "start@example.com", name = "Start") + ) + + formica.onChange(UserForm::email, "bad") + formica.validateField(UserForm::email) + + assertTrue(formica.isDirty.value) + assertTrue(formica.isTouched.value) + assertEquals("Email is invalid", formica.fieldState(UserForm::email).error.value) + + formica.reset() + + assertEquals("start@example.com", formica.data.value.email) + assertFalse(formica.isDirty.value) + assertFalse(formica.isTouched.value) + assertFalse(formica.hasErrors.value) + assertIs(formica.submitResult.value) + + val emailField = formica.fieldState(UserForm::email) + assertEquals("start@example.com", emailField.value.value) + assertFalse(emailField.dirty.value) + assertFalse(emailField.touched.value) + assertNull(emailField.error.value) + assertIs(emailField.validationResult.value) + } + + @Test + fun `replaceData behaves like reset to new snapshot`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "a@b.com", name = "Old") + ) + + formica.onChange(UserForm::name, "Changed") + + formica.replaceData( + UserForm( + email = "new@example.com", + name = "New", + age = 40, + active = true, + note = "hello" + ) + ) + + assertEquals("new@example.com", formica.data.value.email) + assertEquals("New", formica.data.value.name) + assertFalse(formica.isDirty.value) + assertFalse(formica.isTouched.value) + assertNull(formica.fieldState(UserForm::name).error.value) + } + + @Test + fun `clear uses field clear function and updates data`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "john@example.com", name = "John", note = "abc") + ) + + formica.clear(UserForm::note) + + assertNull(formica.data.value.note) + assertTrue(formica.fieldState(UserForm::note).touched.value) + assertTrue(formica.fieldState(UserForm::note).dirty.value) + } + + @Test + fun `hidden or disabled field is skipped during field validation`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm( + email = "john@example.com", + name = "John", + active = false, + note = "" + ) + ) + + val noteState = formica.fieldState(UserForm::note) + assertFalse(noteState.visible.value) + assertFalse(noteState.enabled.value) + + val result = formica.validateField(UserForm::note) + + assertIs(result) + assertIs(noteState.validationResult.value) + assertNull(noteState.error.value) + } + + @Test + fun `hidden or disabled field is ignored in full validation`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm( + email = "john@example.com", + name = "John", + active = false, + note = "" + ) + ) + + val result = formica.validate() + + assertTrue(result.isValid) + assertFalse(result.fieldErrors.containsKey("note")) + assertNull(formica.fieldState(UserForm::note).error.value) + } + + @Test + fun `availability reacts when controlling data changes`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(active = true, note = "hello") + ) + + assertTrue(formica.fieldState(UserForm::note).visible.value) + assertTrue(formica.fieldState(UserForm::note).enabled.value) + + formica.onChange(UserForm::active, false) + + assertFalse(formica.fieldState(UserForm::note).visible.value) + assertFalse(formica.fieldState(UserForm::note).enabled.value) + } + + @Test + fun `validateOnChange true validates immediately`() { + val formica = Formica( + adapter = adapter(emailValidateOnChange = true), + initialData = UserForm() + ) + + formica.onChange(UserForm::email, "bad") + + assertEquals("Email is invalid", formica.fieldState(UserForm::email).error.value) + assertTrue(formica.hasErrors.value) + } + + @Test + fun `validateOnChange false does not validate automatically`() { + val formica = Formica( + adapter = adapter(emailValidateOnChange = false), + initialData = UserForm() + ) + + formica.onChange(UserForm::email, "bad") + + assertNull(formica.fieldState(UserForm::email).error.value) + assertFalse(formica.hasErrors.value) + + val result = formica.validateField(UserForm::email) + assertIs(result) + assertEquals("Email is invalid", formica.fieldState(UserForm::email).error.value) + } + + @Test + fun `snapshot returns current aggregate state`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "", name = "") + ) + + formica.onChange(UserForm::email, "bad") + formica.validateField(UserForm::email) + + val snapshot = formica.snapshot() + + assertEquals("bad", snapshot.data.email) + assertTrue(snapshot.isDirty) + assertTrue(snapshot.isTouched) + assertTrue(snapshot.hasErrors) + assertFalse(snapshot.canSubmit) + assertEquals("Email is invalid", snapshot.fieldErrors["email"]) + } + + @Test + fun `fieldSnapshot returns current field aggregate state`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm() + ) + + formica.onChange(UserForm::name, "John") + + val snapshot = formica.fieldSnapshot(UserForm::name) + + assertEquals("name", snapshot.key) + assertEquals("John", snapshot.value) + assertTrue(snapshot.dirty) + assertTrue(snapshot.touched) + assertTrue(snapshot.visible) + assertTrue(snapshot.enabled) + assertNull(snapshot.error) + } + + @Test + fun `formErrors are propagated from adapter`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "global@error.com", name = "John") + ) + + val result = formica.validate() + + assertFalse(result.isValid) + assertEquals(listOf("Global form error"), result.formErrors) + assertEquals(listOf("Global form error"), formica.formErrors.value) + assertIs(formica.submitResult.value) + } + + @Test + fun `canSubmit is true when dirty and no current field errors`() { + val formica = Formica( + adapter = adapter(), + initialData = UserForm(email = "john@example.com", name = "John") + ) + + formica.onChange(UserForm::name, "Johnny") + + assertTrue(formica.isDirty.value) + assertFalse(formica.hasErrors.value) + assertTrue(formica.canSubmit.value) + } + + @Test + fun `fieldState throws for unknown property`() { + data class Other(val value: String) + + val formica = Formica( + adapter = adapter(), + initialData = UserForm() + ) + + try { + @Suppress("UNCHECKED_CAST") + formica.fieldState(Other::value as kotlin.reflect.KProperty1) + kotlin.test.fail("Expected error for unknown property") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("is not registered in adapter")) + } + } +} From 300f98ab5b53efcb778eeaaac5c0c1812738b288 Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Fri, 17 Apr 2026 18:09:52 +0200 Subject: [PATCH 3/6] Schema packages --- .../formica/schema/FieldValidationResult.kt | 12 + .../kotlin/dev/voir/formica/schema/Schema.kt | 94 ++++ .../dev/voir/formica/schema/SchemaBuilder.kt | 61 +++ .../formica/schema/SchemaDslExtensions.kt | 206 +++++++++ .../dev/voir/formica/schema/SchemaField.kt | 23 + .../voir/formica/schema/SchemaFieldBuilder.kt | 60 +++ .../voir/formica/schema/SchemaObjectRule.kt | 15 + .../dev/voir/formica/schema/ValidationRule.kt | 30 ++ .../voir/formica/schema/ValidationRules.kt | 324 ++++++++++++++ .../voir/formica/schema/SchemaBuilderTest.kt | 253 +++++++++++ .../schema/SchemaCoreIntegrationTest.kt | 105 +++++ .../schema/ValidationRuleExtensionsTest.kt | 58 +++ .../formica/schema/ValidationRulesTest.kt | 411 ++++++++++++++++++ 13 files changed, 1652 insertions(+) create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaField.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaFieldBuilder.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaObjectRule.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt create mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaBuilderTest.kt create mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaCoreIntegrationTest.kt create mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRuleExtensionsTest.kt create mode 100644 formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRulesTest.kt diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt new file mode 100644 index 0000000..b80320c --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/FieldValidationResult.kt @@ -0,0 +1,12 @@ +package dev.voir.formica.schema + +/** + * Result returned by schema rules. + * + * This maps naturally to formica-core field validation results. + */ +sealed interface FieldValidationResult { + data object Success : FieldValidationResult + data object Skip : FieldValidationResult + data class Error(val message: String) : FieldValidationResult +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt new file mode 100644 index 0000000..688d39f --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/Schema.kt @@ -0,0 +1,94 @@ +package dev.voir.formica.schema + +import dev.voir.formica.core.FormicaFieldDefinition +import dev.voir.formica.core.FormicaFieldValidationResult +import dev.voir.formica.core.FormicaValidationAdapter +import dev.voir.formica.core.FormicaValidationResult + +/** + * Default schema implementation for Formica. + * + * Validation strategy: + * - field rules run in declared order + * - first field error wins for that field + * - object rules run after field rules + */ +class Schema( + private val schemaFields: List>, + private val objectRules: List> = emptyList() +) : FormicaValidationAdapter { + + override val fields: List> + get() = schemaFields + + override fun validate(data: Data): FormicaValidationResult { + val fieldErrors = linkedMapOf() + + for (field in schemaFields) { + @Suppress("UNCHECKED_CAST") + val typed = field as SchemaField + + val result = validateRules( + value = typed.property.get(data), + rules = typed.rules + ) + + if (result is FieldValidationResult.Error) { + fieldErrors[typed.key] = result.message + } + } + + val formErrors = mutableListOf() + + for (rule in objectRules) { + val result = rule.validate(data) + for ((key, message) in result.fieldErrors) { + if (!fieldErrors.containsKey(key)) { + fieldErrors[key] = message + } + } + formErrors += result.formErrors + } + + return FormicaValidationResult( + data = data, + fieldErrors = fieldErrors, + formErrors = formErrors + ) + } + + override fun validateField( + data: Data, + fieldKey: String + ): FormicaFieldValidationResult { + val field = schemaFields.firstOrNull { it.key == fieldKey } + ?: return FormicaFieldValidationResult.Skip + + @Suppress("UNCHECKED_CAST") + val typed = field as SchemaField + + return when ( + val result = validateRules( + value = typed.property.get(data), + rules = typed.rules + ) + ) { + FieldValidationResult.Success -> FormicaFieldValidationResult.Success + FieldValidationResult.Skip -> FormicaFieldValidationResult.Skip + is FieldValidationResult.Error -> FormicaFieldValidationResult.Error(result.message) + } + } + + private fun validateRules( + value: T, + rules: List> + ): FieldValidationResult { + for (rule in rules) { + when (val result = rule.validate(value)) { + is FieldValidationResult.Error -> return result + else -> Unit + } + } + return FieldValidationResult.Success + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt new file mode 100644 index 0000000..b3ae1ae --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaBuilder.kt @@ -0,0 +1,61 @@ +package dev.voir.formica.schema + +import kotlin.reflect.KProperty1 + +/** + * Root schema builder. + */ +class SchemaBuilder { + private val fields = mutableListOf>() + private val objectRules = mutableListOf>() + + /** + * Register one field. + * + * key defaults to property.name, but you can override it if needed. + */ + fun field( + property: KProperty1, + set: (Data, Value?) -> Data, + clear: ((Data) -> Data)? = null, + key: String = property.name, + block: SchemaFieldBuilder.() -> Unit = {} + ): SchemaField { + val builder = SchemaFieldBuilder( + key = key, + property = property, + set = set, + clear = clear + ) + builder.block() + return builder.build().also(fields::add) + } + + /** + * Add a cross-field validation rule. + */ + fun objectRule(rule: ObjectValidationRule) { + objectRules += rule + } + + /** + * Convenience overload. + */ + fun objectRule(block: (Data) -> ObjectRuleResult) { + objectRules += ObjectValidationRule(block) + } + + internal fun build(): Schema = + Schema( + schemaFields = fields.toList(), + objectRules = objectRules.toList() + ) +} + +/** + * Entry point for building a schema. + */ +fun schema( + block: SchemaBuilder.() -> Unit +): Schema = + SchemaBuilder().apply(block).build() diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt new file mode 100644 index 0000000..3ea6f9e --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt @@ -0,0 +1,206 @@ +package dev.voir.formica.schema + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.required( + message: String = "Field is required", + isEmpty: (Value?) -> Boolean = { value -> + when (value) { + null -> true + is String -> value.isBlank() + is Collection<*> -> value.isEmpty() + else -> false + } + } +) { + rule(ValidationRules.required(message, isEmpty) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.validateOnlyIf( + active: () -> Boolean, + rule: ValidationRule +) { + this.rule(ValidationRules.validateOnlyIf(active, rule) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.notEmpty( + message: String = "This field cannot be empty." +) { + rule(ValidationRules.notEmpty(message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.notBlank( + message: String = "This field cannot be blank." +) { + rule(ValidationRules.notBlank(message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.email( + message: String = "Must be a valid email address.", + pattern: Regex = Regex(EMAIL_PATTERN) +) { + rule(ValidationRules.email(message, pattern) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.strongPassword( + minLength: Int = 8, + lengthMessage: String = "Password must be at least $minLength characters long.", + uppercaseMessage: String = "Password must contain at least one uppercase letter.", + lowercaseMessage: String = "Password must contain at least one lowercase letter.", + digitMessage: String = "Password must contain at least one digit.", + specialCharacterMessage: String = "Password must contain at least one special character.", +) { + rule( + ValidationRules.strongPassword( + minLength = minLength, + lengthMessage = lengthMessage, + uppercaseMessage = uppercaseMessage, + lowercaseMessage = lowercaseMessage, + digitMessage = digitMessage, + specialCharacterMessage = specialCharacterMessage + ) as ValidationRule + ) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.url( + protocolRequired: Boolean = false, + message: String = "Must be a valid URL." +) { + rule(ValidationRules.url(protocolRequired, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.checked( + message: String = "Must be checked" +) { + rule(ValidationRules.checked(message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.minLength( + option: Int, + message: String = "Must be at least $option characters long." +) { + rule(ValidationRules.minLength(option, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.maxLength( + option: Int, + message: String = "Must not exceed $option characters." +) { + rule(ValidationRules.maxLength(option, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.range( + min: T, + max: T, + inclusive: Boolean = true, + message: (T, T) -> String = { lo, hi -> "Must be a number between $lo and $hi." } +) where T : Number, T : Comparable { + rule(ValidationRules.range(min, max, inclusive, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.range( + min: Double, + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double, Double) -> String = { lo, hi -> "Must be a number between $lo and $hi." } +) { + rule(ValidationRules.range(min, max, inclusive, epsilon, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.range( + min: Float, + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float, Float) -> String = { lo, hi -> "Must be a number between $lo and $hi." } +) { + rule(ValidationRules.range(min, max, inclusive, epsilon, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.min( + min: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.max( + max: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.min( + min: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.max( + max: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.min( + min: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, epsilon, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.max( + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, epsilon, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.min( + min: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } +) { + rule(ValidationRules.min(min, inclusive, epsilon, message) as ValidationRule) +} + +@Suppress("UNCHECKED_CAST") +fun SchemaFieldBuilder.max( + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } +) { + rule(ValidationRules.max(max, inclusive, epsilon, message) as ValidationRule) +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaField.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaField.kt new file mode 100644 index 0000000..93c04c9 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaField.kt @@ -0,0 +1,23 @@ +package dev.voir.formica.schema + + +import dev.voir.formica.core.FormicaFieldDefinition +import kotlin.reflect.KProperty1 + +/** + * One schema field definition. + * + * Stores: + * - runtime field metadata needed by formica-core + * - rules used by this schema implementation + */ +data class SchemaField( + override val key: String, + override val property: KProperty1, + override val set: (Data, Value?) -> Data, + override val clear: ((Data) -> Data)? = null, + override val validateOnChange: Boolean = true, + override val isVisible: (Data) -> Boolean = { true }, + override val isEnabled: (Data) -> Boolean = { true }, + val rules: List> = emptyList() +) : FormicaFieldDefinition diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaFieldBuilder.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaFieldBuilder.kt new file mode 100644 index 0000000..b083704 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaFieldBuilder.kt @@ -0,0 +1,60 @@ +package dev.voir.formica.schema + +import kotlin.reflect.KProperty1 + +/** + * Builder for one field inside a schema. + */ +class SchemaFieldBuilder( + private val key: String, + private val property: KProperty1, + private val set: (Data, Value?) -> Data, + private val clear: ((Data) -> Data)? +) { + private val rules = mutableListOf>() + private var validateOnChange: Boolean = true + private var visible: (Data) -> Boolean = { true } + private var enabled: (Data) -> Boolean = { true } + + /** + * Add a raw rule. + */ + fun rule(rule: ValidationRule) { + rules += rule + } + + /** + * Control if Formica validates this field immediately on change. + */ + fun validateOnChange(value: Boolean) { + validateOnChange = value + } + + /** + * Control if the field is currently visible. + * Hidden fields are filtered by formica-core. + */ + fun visibleWhen(predicate: (Data) -> Boolean) { + visible = predicate + } + + /** + * Control if the field is currently enabled. + * Disabled fields are filtered by formica-core. + */ + fun enabledWhen(predicate: (Data) -> Boolean) { + enabled = predicate + } + + internal fun build(): SchemaField = + SchemaField( + key = key, + property = property, + set = set, + clear = clear, + validateOnChange = validateOnChange, + isVisible = visible, + isEnabled = enabled, + rules = rules.toList() + ) +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaObjectRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaObjectRule.kt new file mode 100644 index 0000000..c711e92 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaObjectRule.kt @@ -0,0 +1,15 @@ +package dev.voir.formica.schema + +/** + * Cross-field validation rule. + * + * Return field-keyed errors or form-level errors as needed. + */ +data class ObjectRuleResult( + val fieldErrors: Map = emptyMap(), + val formErrors: List = emptyList() +) + +fun interface ObjectValidationRule { + fun validate(data: Data): ObjectRuleResult +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt new file mode 100644 index 0000000..ef17901 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRule.kt @@ -0,0 +1,30 @@ +package dev.voir.formica.schema + +/** + * A reusable validation unit for one value. + * + * Rules should be side-effect free. + */ +fun interface ValidationRule { + fun validate(value: T): FieldValidationResult +} + +/** + * Combine rules and stop at the first error. + * Skip does not stop the chain. + */ +fun ValidationRule.and(other: ValidationRule): ValidationRule = + ValidationRule { value -> + when (val first = validate(value)) { + is FieldValidationResult.Error -> first + else -> other.validate(value) + } + } + +/** + * Wrap a rule so it only runs when [predicate] is true. + */ +fun ValidationRule.onlyIf(predicate: () -> Boolean): ValidationRule = + ValidationRule { value -> + if (predicate()) validate(value) else FieldValidationResult.Success + } diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt new file mode 100644 index 0000000..6e9021f --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/ValidationRules.kt @@ -0,0 +1,324 @@ +package dev.voir.formica.schema + + +private const val SPECIAL_PASSWORD_CHARS = "!@#$%^&*()-_=+[]{};:'\",.<>?/|\\`~" + +internal const val EMAIL_PATTERN = "[a-zA-Z0-9+._%\\-]{1,256}@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}" + + "(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+" + +internal const val DOMAIN_URL_PATTERN = + "^[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*$" + +internal const val HTTP_URL_PATTERN = + "^https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_+.~#?&/=]*$" + +private val EMAIL_REGEX = Regex(EMAIL_PATTERN) +private val DOMAIN_URL_REGEX = Regex(DOMAIN_URL_PATTERN) +private val HTTP_URL_REGEX = Regex(HTTP_URL_PATTERN) + +/** + * Built-in reusable rules. + * + * These rules intentionally keep your semantics: + * - optional/null values mostly return Skip + * - required is generic and treats null/blank/empty collection as empty by default + */ +object ValidationRules { + + fun validateOnlyIf( + active: () -> Boolean, + rule: ValidationRule + ): ValidationRule = + ValidationRule { value -> + if (active()) rule.validate(value) else FieldValidationResult.Success + } + + fun required( + message: String = "Field is required", + isEmpty: (V?) -> Boolean = { value -> + when (value) { + null -> true + is String -> value.isBlank() + is Collection<*> -> value.isEmpty() + else -> false + } + } + ): ValidationRule = + ValidationRule { value -> + if (isEmpty(value)) { + FieldValidationResult.Error(message) + } else { + FieldValidationResult.Success + } + } + + fun notEmpty( + message: String = "This field cannot be empty." + ): ValidationRule = + ValidationRule { value -> + when { + value == null -> FieldValidationResult.Skip + value.isNotEmpty() -> FieldValidationResult.Success + else -> FieldValidationResult.Error(message) + } + } + + fun notBlank( + message: String = "This field cannot be blank." + ): ValidationRule = + ValidationRule { value -> + when { + value == null -> FieldValidationResult.Skip + value.isNotBlank() -> FieldValidationResult.Success + else -> FieldValidationResult.Error(message) + } + } + + fun email( + message: String = "Must be a valid email address.", + pattern: Regex = EMAIL_REGEX + ): ValidationRule = + ValidationRule { value -> + when { + value == null -> FieldValidationResult.Skip + pattern.matches(value) -> FieldValidationResult.Success + else -> FieldValidationResult.Error(message) + } + } + + fun strongPassword( + minLength: Int = 8, + lengthMessage: String = "Password must be at least $minLength characters long.", + uppercaseMessage: String = "Password must contain at least one uppercase letter.", + lowercaseMessage: String = "Password must contain at least one lowercase letter.", + digitMessage: String = "Password must contain at least one digit.", + specialCharacterMessage: String = "Password must contain at least one special character.", + ): ValidationRule = + ValidationRule { value -> + when { + value == null -> FieldValidationResult.Skip + value.length < minLength -> FieldValidationResult.Error(lengthMessage) + !value.any(Char::isUpperCase) -> FieldValidationResult.Error(uppercaseMessage) + !value.any(Char::isLowerCase) -> FieldValidationResult.Error(lowercaseMessage) + !value.any(Char::isDigit) -> FieldValidationResult.Error(digitMessage) + !value.any { it in SPECIAL_PASSWORD_CHARS } -> + FieldValidationResult.Error(specialCharacterMessage) + + else -> FieldValidationResult.Success + } + } + + fun url( + protocolRequired: Boolean = false, + message: String = "Must be a valid URL." + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + + val valid = if (protocolRequired) { + HTTP_URL_REGEX.matches(value) + } else { + HTTP_URL_REGEX.matches(value) || DOMAIN_URL_REGEX.matches(value) + } + + if (valid) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message) + } + } + + fun checked( + message: String = "Must be checked" + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + if (value) FieldValidationResult.Success else FieldValidationResult.Error(message) + } + + fun minLength( + option: Int, + message: String = "Must be at least $option characters long.", + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + if (value.length >= option) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message) + } + } + + fun maxLength( + option: Int, + message: String = "Must not exceed $option characters.", + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + if (value.length <= option) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message) + } + } + + fun range( + min: T, + max: T, + inclusive: Boolean = true, + message: (min: T, max: T) -> String = { lo, hi -> + "Must be a number between $lo and $hi." + } + ): ValidationRule where T : Number, T : Comparable = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + + val ok = if (inclusive) { + value >= min && value <= max + } else { + value > min && value < max + } + + if (ok) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message(min, max)) + } + } + + fun range( + min: Double, + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double, Double) -> String = { lo, hi -> + "Must be a number between $lo and $hi." + } + ): ValidationRule = + ValidationRule { value -> + if (value == null || value.isNaN()) return@ValidationRule FieldValidationResult.Skip + + val lower = if (inclusive) value >= min - epsilon else value > min + epsilon + val upper = if (inclusive) value <= max + epsilon else value < max - epsilon + + if (lower && upper) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message(min, max)) + } + } + + fun range( + min: Float, + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float, Float) -> String = { lo, hi -> + "Must be a number between $lo and $hi." + } + ): ValidationRule = + ValidationRule { value -> + if (value == null || value.isNaN()) return@ValidationRule FieldValidationResult.Skip + + val lower = if (inclusive) value >= min - epsilon else value > min + epsilon + val upper = if (inclusive) value <= max + epsilon else value < max - epsilon + + if (lower && upper) { + FieldValidationResult.Success + } else { + FieldValidationResult.Error(message(min, max)) + } + } + + fun min( + min: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value >= min else value > min + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(min)) + } + + fun max( + max: Int, + inclusive: Boolean = true, + message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value <= max else value < max + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(max)) + } + + fun min( + min: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value >= min else value > min + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(min)) + } + + fun max( + max: Long, + inclusive: Boolean = true, + message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value <= max else value < max + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(max)) + } + + fun min( + min: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null || value.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value >= min - epsilon else value > min + epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(min)) + } + + fun max( + max: Double, + inclusive: Boolean = true, + epsilon: Double = 0.0, + message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null || value.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value <= max + epsilon else value < max - epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(max)) + } + + fun min( + min: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null || value.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value >= min - epsilon else value > min + epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(min)) + } + + fun max( + max: Float, + inclusive: Boolean = true, + epsilon: Float = 0f, + message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } + ): ValidationRule = + ValidationRule { value -> + if (value == null || value.isNaN()) return@ValidationRule FieldValidationResult.Skip + val ok = if (inclusive) value <= max + epsilon else value < max - epsilon + if (ok) FieldValidationResult.Success else FieldValidationResult.Error(message(max)) + } +} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaBuilderTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaBuilderTest.kt new file mode 100644 index 0000000..592b5da --- /dev/null +++ b/formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaBuilderTest.kt @@ -0,0 +1,253 @@ +package dev.voir.formica.schema + +import dev.voir.formica.core.FormicaFieldValidationResult +import kotlin.test.* + +class SchemaBuilderTest { + + data class User( + val email: String = "", + val name: String = "", + val address: String? = null, + val age: Int? = null, + val accepted: Boolean = false, + val active: Boolean = true + ) + + private val userSchema = schema { + field( + property = User::email, + set = { data, value -> data.copy(email = value ?: "") } + ) { + required("Email is required") + email("Email is invalid") + } + + field( + property = User::name, + set = { data, value -> data.copy(name = value ?: "") } + ) { + notBlank("Name is required") + maxLength(10) + } + + field( + property = User::address, + set = { data, value -> data.copy(address = value) }, + clear = { data -> data.copy(address = null) } + ) { + visibleWhen { it.active } + enabledWhen { it.active } + notBlank("Address cannot be blank if provided") + } + + field( + property = User::age, + set = { data, value -> data.copy(age = value) }, + clear = { data -> data.copy(age = null) } + ) { + min(18) + } + + field( + property = User::accepted, + set = { data, value -> data.copy(accepted = value ?: false) } + ) { + checked("Must accept terms") + } + + objectRule { user -> + if (user.name == user.email && user.name.isNotBlank()) { + ObjectRuleResult( + fieldErrors = mapOf("name" to "Name must not equal email"), + formErrors = listOf("Form level issue") + ) + } else { + ObjectRuleResult() + } + } + } + + @Test + fun `schema exposes registered fields`() { + assertEquals(5, userSchema.fields.size) + assertEquals( + listOf("email", "name", "address", "age", "accepted"), + userSchema.fields.map { it.key } + ) + } + + @Test + fun `field metadata is preserved`() { + val address = userSchema.fields.first { it.key == "address" } + + assertEquals("address", address.key) + assertEquals("address", address.property.name) + assertTrue(address.validateOnChange) + } + + @Test + fun `validateField returns first field error`() { + val result = userSchema.validateField( + data = User(email = "", name = "John"), + fieldKey = "email" + ) + + assertIs(result) + assertEquals("Email is required", result.message) + } + + @Test + fun `validateField succeeds for valid field`() { + val result = userSchema.validateField( + data = User(email = "john@example.com"), + fieldKey = "email" + ) + + assertEquals(FormicaFieldValidationResult.Success, result) + } + + @Test + fun `validateField returns skip for unknown key`() { + val result = userSchema.validateField( + data = User(), + fieldKey = "missing" + ) + + assertEquals(FormicaFieldValidationResult.Skip, result) + } + + @Test + fun `validate collects field errors`() { + val result = userSchema.validate( + User( + email = "bad", + name = "", + address = "", + age = 15, + accepted = false, + active = true + ) + ) + + assertFalse(result.isValid) + assertEquals("Email is invalid", result.fieldErrors["email"]) + assertEquals("Name is required", result.fieldErrors["name"]) + assertEquals("Address cannot be blank if provided", result.fieldErrors["address"]) + assertEquals("Must be >= 18.", result.fieldErrors["age"]) + assertEquals("Must accept terms", result.fieldErrors["accepted"]) + } + + @Test + fun `validate succeeds for valid object`() { + val result = userSchema.validate( + User( + email = "john@example.com", + name = "John", + address = "Street 1", + age = 30, + accepted = true + ) + ) + + assertTrue(result.isValid) + assertTrue(result.fieldErrors.isEmpty()) + assertTrue(result.formErrors.isEmpty()) + } + + @Test + fun `object rule adds field and form errors`() { + val result = userSchema.validate( + User( + email = "same", + name = "same", + accepted = true + ) + ) + + assertFalse(result.isValid) + assertEquals("Name must not equal email", result.fieldErrors["name"]) + assertEquals(listOf("Form level issue"), result.formErrors) + } + + @Test + fun `field level error wins over object rule duplicate`() { + val result = userSchema.validate( + User( + email = "", + name = "", + accepted = true + ) + ) + + assertEquals("Name is required", result.fieldErrors["name"]) + } + + @Test + fun `set function updates immutable data`() { + val emailField = userSchema.fields.first { it.key == "email" } + + @Suppress("UNCHECKED_CAST") + val typed = emailField as SchemaField + + val updated = typed.set( + User(email = "old@example.com"), + "new@example.com" + ) + + assertEquals("new@example.com", updated.email) + } + + @Test + fun `clear function clears nullable data when provided`() { + val addressField = userSchema.fields.first { it.key == "address" } + + @Suppress("UNCHECKED_CAST") + val typed = addressField as SchemaField + + val cleared = typed.clear!!.invoke( + User(address = "Street 1") + ) + + assertNull(cleared.address) + } + + @Test + fun `visibleWhen predicate is preserved`() { + val addressField = userSchema.fields.first { it.key == "address" } + + @Suppress("UNCHECKED_CAST") + val typed = addressField as SchemaField + + assertTrue(typed.isVisible(User(active = true))) + assertFalse(typed.isVisible(User(active = false))) + } + + @Test + fun `enabledWhen predicate is preserved`() { + val addressField = userSchema.fields.first { it.key == "address" } + + @Suppress("UNCHECKED_CAST") + val typed = addressField as SchemaField + + assertTrue(typed.isEnabled(User(active = true))) + assertFalse(typed.isEnabled(User(active = false))) + } + + @Test + fun `validateOnChange can be overridden`() { + val schema = schema { + field( + property = User::email, + set = { data, value -> data.copy(email = value ?: "") } + ) { + validateOnChange(false) + email() + } + } + + val field = schema.fields.first() + + assertFalse(field.validateOnChange) + } +} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaCoreIntegrationTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaCoreIntegrationTest.kt new file mode 100644 index 0000000..005b972 --- /dev/null +++ b/formica/src/commonTest/kotlin/dev/voir/formica/schema/SchemaCoreIntegrationTest.kt @@ -0,0 +1,105 @@ +package dev.voir.formica.schema + +import dev.voir.formica.core.Formica +import dev.voir.formica.core.FormicaFieldValidationResult +import dev.voir.formica.core.FormicaSubmitResult +import kotlin.test.* + +class SchemaCoreIntegrationTest { + + data class User( + val email: String = "", + val name: String = "", + val active: Boolean = true, + val note: String? = null + ) + + private val schema = schema { + field( + property = User::email, + set = { data, value -> data.copy(email = value ?: "") } + ) { + required("Email required") + email("Email invalid") + } + + field( + property = User::name, + set = { data, value -> data.copy(name = value ?: "") } + ) { + notBlank("Name required") + } + + field( + property = User::note, + set = { data, value -> data.copy(note = value) }, + clear = { data -> data.copy(note = null) } + ) { + visibleWhen { it.active } + enabledWhen { it.active } + notBlank("Note invalid") + } + } + + @Test + fun `schema works with formica core field validation`() { + val formica = Formica( + adapter = schema, + initialData = User(email = "", name = "John") + ) + + val result = formica.validateField(User::email) + + assertIs(result) + assertEquals("Email required", result.message) + assertEquals("Email required", formica.fieldState(User::email).error.value) + } + + @Test + fun `schema works with formica core full validation`() { + val formica = Formica( + adapter = schema, + initialData = User(email = "bad", name = "") + ) + + val result = formica.validate() + + assertFalse(result.isValid) + assertEquals("Email invalid", result.fieldErrors["email"]) + assertEquals("Name required", result.fieldErrors["name"]) + assertIs(formica.submitResult.value) + } + + @Test + fun `hidden or disabled schema field is filtered by formica core`() { + val formica = Formica( + adapter = schema, + initialData = User( + email = "john@example.com", + name = "John", + active = false, + note = "" + ) + ) + + val result = formica.validate() + + assertTrue(result.isValid) + assertNull(result.fieldErrors["note"]) + assertNull(formica.fieldState(User::note).error.value) + } + + @Test + fun `schema set functions update immutable data through formica`() { + val formica = Formica( + adapter = schema, + initialData = User() + ) + + formica.onChange(User::email, "john@example.com") + formica.onChange(User::name, "John") + + assertEquals("john@example.com", formica.data.value.email) + assertEquals("John", formica.data.value.name) + } +} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRuleExtensionsTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRuleExtensionsTest.kt new file mode 100644 index 0000000..16be7eb --- /dev/null +++ b/formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRuleExtensionsTest.kt @@ -0,0 +1,58 @@ +package dev.voir.formica.schema + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ValidationRuleExtensionsTest { + + @Test + fun `and returns first error if first rule fails`() { + val rule = ValidationRules.notBlank("blank") + .and(ValidationRules.minLength(5, "short")) + + val result = rule.validate("") + + assertIs(result) + assertEquals("blank", result.message) + } + + @Test + fun `and returns second error if first succeeds and second fails`() { + val rule = ValidationRules.notBlank("blank") + .and(ValidationRules.minLength(5, "short")) + + val result = rule.validate("abc") + + assertIs(result) + assertEquals("short", result.message) + } + + @Test + fun `and succeeds when both succeed`() { + val rule = ValidationRules.notBlank("blank") + .and(ValidationRules.minLength(3, "short")) + + val result = rule.validate("abcd") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `onlyIf skips when predicate is false`() { + val rule = ValidationRules.required().onlyIf { false } + + val result = rule.validate(null) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `onlyIf evaluates when predicate is true`() { + val rule = ValidationRules.required().onlyIf { true } + + val result = rule.validate(null) + + assertIs(result) + } +} diff --git a/formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRulesTest.kt b/formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRulesTest.kt new file mode 100644 index 0000000..65931ec --- /dev/null +++ b/formica/src/commonTest/kotlin/dev/voir/formica/schema/ValidationRulesTest.kt @@ -0,0 +1,411 @@ +package dev.voir.formica.schema + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class ValidationRulesTest { + + @Test + fun `required fails for null`() { + val result = ValidationRules.required().validate(null) + + assertIs(result) + assertEquals("Field is required", result.message) + } + + @Test + fun `required fails for blank string`() { + val result = ValidationRules.required().validate(" ") + + assertIs(result) + assertEquals("Field is required", result.message) + } + + @Test + fun `required fails for empty collection`() { + val result = ValidationRules.required>().validate(emptyList()) + + assertIs(result) + } + + @Test + fun `required succeeds for non-empty value`() { + val result = ValidationRules.required().validate("John") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `validateOnlyIf skips when inactive`() { + val rule = ValidationRules.validateOnlyIf( + active = { false }, + rule = ValidationRules.required() + ) + + val result = rule.validate(null) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `validateOnlyIf delegates when active`() { + val rule = ValidationRules.validateOnlyIf( + active = { true }, + rule = ValidationRules.required() + ) + + val result = rule.validate(null) + + assertIs(result) + } + + @Test + fun `notEmpty skips null`() { + val result = ValidationRules.notEmpty().validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `notEmpty fails for empty string`() { + val result = ValidationRules.notEmpty().validate("") + + assertIs(result) + assertEquals("This field cannot be empty.", result.message) + } + + @Test + fun `notEmpty succeeds for blank but non-empty string`() { + val result = ValidationRules.notEmpty().validate(" ") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `notBlank skips null`() { + val result = ValidationRules.notBlank().validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `notBlank fails for blank string`() { + val result = ValidationRules.notBlank().validate(" ") + + assertIs(result) + assertEquals("This field cannot be blank.", result.message) + } + + @Test + fun `notBlank succeeds for non-blank string`() { + val result = ValidationRules.notBlank().validate("abc") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `email skips null`() { + val result = ValidationRules.email().validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `email fails for invalid address`() { + val result = ValidationRules.email().validate("wrong") + + assertIs(result) + assertEquals("Must be a valid email address.", result.message) + } + + @Test + fun `email succeeds for valid address`() { + val result = ValidationRules.email().validate("john@example.com") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `strongPassword skips null`() { + val result = ValidationRules.strongPassword().validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `strongPassword fails for short password`() { + val result = ValidationRules.strongPassword().validate("Aa1!") + + assertIs(result) + assertEquals("Password must be at least 8 characters long.", result.message) + } + + @Test + fun `strongPassword fails without uppercase`() { + val result = ValidationRules.strongPassword().validate("lowercase1!") + + assertIs(result) + assertEquals("Password must contain at least one uppercase letter.", result.message) + } + + @Test + fun `strongPassword fails without lowercase`() { + val result = ValidationRules.strongPassword().validate("UPPERCASE1!") + + assertIs(result) + assertEquals("Password must contain at least one lowercase letter.", result.message) + } + + @Test + fun `strongPassword fails without digit`() { + val result = ValidationRules.strongPassword().validate("Password!") + + assertIs(result) + assertEquals("Password must contain at least one digit.", result.message) + } + + @Test + fun `strongPassword fails without special character`() { + val result = ValidationRules.strongPassword().validate("Password1") + + assertIs(result) + assertEquals("Password must contain at least one special character.", result.message) + } + + @Test + fun `strongPassword succeeds for valid password`() { + val result = ValidationRules.strongPassword().validate("Password1!") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `url skips null`() { + val result = ValidationRules.url().validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `url accepts domain when protocol not required`() { + val result = ValidationRules.url(protocolRequired = false).validate("example.com") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `url rejects domain when protocol required`() { + val result = ValidationRules.url(protocolRequired = true).validate("example.com") + + assertIs(result) + } + + @Test + fun `url accepts full http url when protocol required`() { + val result = ValidationRules.url(protocolRequired = true).validate("https://example.com") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `checked skips null`() { + val result = ValidationRules.checked().validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `checked fails for false`() { + val result = ValidationRules.checked().validate(false) + + assertIs(result) + assertEquals("Must be checked", result.message) + } + + @Test + fun `checked succeeds for true`() { + val result = ValidationRules.checked().validate(true) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `minLength skips null`() { + val result = ValidationRules.minLength(3).validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `minLength fails when too short`() { + val result = ValidationRules.minLength(3).validate("ab") + + assertIs(result) + assertEquals("Must be at least 3 characters long.", result.message) + } + + @Test + fun `minLength succeeds when long enough`() { + val result = ValidationRules.minLength(3).validate("abc") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `maxLength skips null`() { + val result = ValidationRules.maxLength(3).validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `maxLength fails when too long`() { + val result = ValidationRules.maxLength(3).validate("abcd") + + assertIs(result) + assertEquals("Must not exceed 3 characters.", result.message) + } + + @Test + fun `maxLength succeeds when short enough`() { + val result = ValidationRules.maxLength(3).validate("abc") + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `generic range skips null`() { + val result = ValidationRules.range(1, 10).validate(null) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `generic range succeeds inside inclusive bounds`() { + val result = ValidationRules.range(1, 10).validate(5) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `generic range fails outside inclusive bounds`() { + val result = ValidationRules.range(1, 10).validate(11) + + assertIs(result) + assertEquals("Must be a number between 1 and 10.", result.message) + } + + @Test + fun `generic range fails at edge when exclusive`() { + val result = ValidationRules.range(1, 10, inclusive = false).validate(1) + + assertIs(result) + } + + @Test + fun `double range skips NaN`() { + val result = ValidationRules.range(1.0, 2.0).validate(Double.NaN) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `double range respects epsilon`() { + val result = ValidationRules.range( + min = 1.0, + max = 2.0, + inclusive = true, + epsilon = 0.01 + ).validate(2.005) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `float range skips NaN`() { + val result = ValidationRules.range(1f, 2f).validate(Float.NaN) + + assertEquals(FieldValidationResult.Skip, result) + } + + @Test + fun `int min fails below bound`() { + val result = ValidationRules.min(5).validate(4) + + assertIs(result) + assertEquals("Must be >= 5.", result.message) + } + + @Test + fun `int min succeeds at inclusive bound`() { + val result = ValidationRules.min(5).validate(5) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `int max fails above bound`() { + val result = ValidationRules.max(5).validate(6) + + assertIs(result) + assertEquals("Must be <= 5.", result.message) + } + + @Test + fun `int max succeeds at inclusive bound`() { + val result = ValidationRules.max(5).validate(5) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `long min succeeds`() { + val result = ValidationRules.min(5L).validate(6L) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `long max succeeds`() { + val result = ValidationRules.max(5L).validate(4L) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `double min respects epsilon`() { + val result = ValidationRules.min( + min = 10.0, + inclusive = true, + epsilon = 0.1 + ).validate(9.95) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `double max respects epsilon`() { + val result = ValidationRules.max( + max = 10.0, + inclusive = true, + epsilon = 0.1 + ).validate(10.05) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `float min succeeds`() { + val result = ValidationRules.min(1f).validate(2f) + + assertEquals(FieldValidationResult.Success, result) + } + + @Test + fun `float max succeeds`() { + val result = ValidationRules.max(2f).validate(1f) + + assertEquals(FieldValidationResult.Success, result) + } +} From e3fa866f4c33c8af0194bb3a8eeab3588067abfc Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Fri, 17 Apr 2026 18:30:02 +0200 Subject: [PATCH 4/6] compose --- formica/build.gradle.kts | 2 +- .../voir/formica/compose/FormicaComposable.kt | 119 +++++++ .../compose/FormicaCompositionLocals.kt | 11 + .../voir/formica/compose/FormicaController.kt | 24 ++ .../formica/compose/FormicaFieldController.kt | 42 +++ .../dev/voir/formica/compose/FormicaScope.kt | 30 ++ .../voir/formica/compose/rememberFormica.kt | 26 ++ .../formica/schema/SchemaDslExtensions.kt | 32 +- .../kotlin/dev/voir/formica/sample/App.kt | 308 +----------------- .../dev/voir/formica/sample/DemoFields.kt | 128 ++++++++ .../dev/voir/formica/sample/DemoFormData.kt | 15 + .../dev/voir/formica/sample/DemoFormSchema.kt | 122 +++++++ .../dev/voir/formica/sample/DemoScreen.kt | 279 ++++++++++++++++ .../formica/sample/ui/FormFieldWrapper.kt | 14 - 14 files changed, 814 insertions(+), 338 deletions(-) create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaComposable.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaCompositionLocals.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaController.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaFieldController.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaScope.kt create mode 100644 formica/src/commonMain/kotlin/dev/voir/formica/compose/rememberFormica.kt create mode 100644 sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFields.kt create mode 100644 sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormData.kt create mode 100644 sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormSchema.kt create mode 100644 sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoScreen.kt delete mode 100644 sample/src/commonMain/kotlin/dev/voir/formica/sample/ui/FormFieldWrapper.kt diff --git a/formica/build.gradle.kts b/formica/build.gradle.kts index 88ff22c..c4d6e72 100644 --- a/formica/build.gradle.kts +++ b/formica/build.gradle.kts @@ -24,7 +24,7 @@ kotlin { implementation(libs.kotlin.reflect) implementation(libs.kotlin.coroutines) - implementation(libs.compose.runtime) + api(libs.compose.runtime) } commonTest.dependencies { implementation(kotlin("test")) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaComposable.kt b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaComposable.kt new file mode 100644 index 0000000..7b04d8e --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaComposable.kt @@ -0,0 +1,119 @@ +package dev.voir.formica.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import dev.voir.formica.core.Formica +import kotlin.reflect.KProperty1 + +@Composable +fun Formica( + formica: Formica, + content: @Composable FormicaScope.(FormicaController) -> Unit +) { + val data = formica.data.collectAsState().value + val isDirty = formica.isDirty.collectAsState().value + val isTouched = formica.isTouched.collectAsState().value + val hasErrors = formica.hasErrors.collectAsState().value + val canSubmit = formica.canSubmit.collectAsState().value + val fieldErrors = formica.fieldErrors.collectAsState().value + val formErrors = formica.formErrors.collectAsState().value + val submitResult = formica.submitResult.collectAsState().value + + val formController = remember( + data, + isDirty, + isTouched, + hasErrors, + canSubmit, + fieldErrors, + formErrors, + submitResult, + formica + ) { + FormicaController( + data = data, + isDirty = isDirty, + isTouched = isTouched, + hasErrors = hasErrors, + canSubmit = canSubmit, + fieldErrors = fieldErrors, + formErrors = formErrors, + submitResult = submitResult, + validate = { formica.validate() }, + submit = { formica.submit() }, + reset = { formica.reset() }, + replaceData = { formica.replaceData(it) } + ) + } + + val scope = remember(formica) { + FormicaScopeImpl(formica) + } + + @Suppress("UNCHECKED_CAST") + CompositionLocalProvider(LocalFormica provides (formica as Formica)) { + scope.content(formController) + } +} + +private class FormicaScopeImpl( + private val formica: Formica +) : FormicaScope { + + @Composable + override fun Field( + property: KProperty1, + content: @Composable (FormicaFieldController) -> Unit + ) { + val controller = field(property) + if (controller.visible) { + content(controller) + } + } + + @Composable + override fun field( + property: KProperty1 + ): FormicaFieldController { + val snapshot = formica.fieldSnapshot(property) + val state = formica.fieldState(property) + + val value = state.value.collectAsState().value + val error = state.error.collectAsState().value + val validationResult = state.validationResult.collectAsState().value + val touched = state.touched.collectAsState().value + val dirty = state.dirty.collectAsState().value + val visible = state.visible.collectAsState().value + val enabled = state.enabled.collectAsState().value + + return remember( + snapshot.key, + value, + error, + validationResult, + touched, + dirty, + visible, + enabled, + formica, + property + ) { + FormicaFieldController( + key = snapshot.key, + value = value, + error = error, + validationResult = validationResult, + touched = touched, + dirty = dirty, + visible = visible, + enabled = enabled, + showError = touched && error != null, + onChange = { formica.onChange(property, it) }, + validate = { formica.validateField(property) }, + clear = { formica.clear(property) } + ) + } + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaCompositionLocals.kt b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaCompositionLocals.kt new file mode 100644 index 0000000..d32ee55 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaCompositionLocals.kt @@ -0,0 +1,11 @@ +package dev.voir.formica.compose + +import androidx.compose.runtime.compositionLocalOf +import dev.voir.formica.core.Formica + +/** + * Internal CompositionLocal used by Formica scope APIs. + * + * Stored as Any? because CompositionLocal cannot be generic itself. + */ +internal val LocalFormica = compositionLocalOf?> { null } diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaController.kt b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaController.kt new file mode 100644 index 0000000..6bf4f48 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaController.kt @@ -0,0 +1,24 @@ +package dev.voir.formica.compose + +import dev.voir.formica.core.FormicaSubmitResult +import dev.voir.formica.core.FormicaValidationResult + +/** + * Read-only Compose-friendly controller for the whole form. + * + * Values here are already collected from StateFlow and are safe to use directly in UI. + */ +data class FormicaController( + val data: Data, + val isDirty: Boolean, + val isTouched: Boolean, + val hasErrors: Boolean, + val canSubmit: Boolean, + val fieldErrors: Map, + val formErrors: List, + val submitResult: FormicaSubmitResult, + val validate: () -> FormicaValidationResult, + val submit: () -> FormicaValidationResult, + val reset: () -> Unit, + val replaceData: (Data) -> Unit +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaFieldController.kt b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaFieldController.kt new file mode 100644 index 0000000..bf71d50 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaFieldController.kt @@ -0,0 +1,42 @@ +package dev.voir.formica.compose + +import dev.voir.formica.core.FormicaFieldValidationResult + +/** + * Read-only Compose-friendly controller for one field. + * + * All values are plain Compose state values, not flows. + */ +data class FormicaFieldController( + val key: String, + val value: Value?, + val error: String?, + val validationResult: FormicaFieldValidationResult, + val touched: Boolean, + val dirty: Boolean, + val visible: Boolean, + val enabled: Boolean, + + /** + * Convenience UI flag. + * + * Default behavior: + * show error only when the field has been touched and there is an error. + */ + val showError: Boolean, + + /** + * Typed field update callback. + */ + val onChange: (Value?) -> Unit, + + /** + * Validate this field now. + */ + val validate: () -> FormicaFieldValidationResult, + + /** + * Clear this field through Formica. + */ + val clear: () -> Unit +) diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaScope.kt b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaScope.kt new file mode 100644 index 0000000..130cb41 --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/compose/FormicaScope.kt @@ -0,0 +1,30 @@ +package dev.voir.formica.compose + +import androidx.compose.runtime.Composable +import kotlin.reflect.KProperty1 + +/** + * Scope receiver available inside Formica { ... }. + * + * Provides typed field access without exposing flows directly. + */ +interface FormicaScope { + + /** + * Render one typed field controller. + */ + @Composable + fun Field( + property: KProperty1, + content: @Composable (FormicaFieldController) -> Unit + ) + + /** + * Convenience helper to get current field controller as a value. + * Useful when composing custom wrappers. + */ + @Composable + fun field( + property: KProperty1 + ): FormicaFieldController +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/compose/rememberFormica.kt b/formica/src/commonMain/kotlin/dev/voir/formica/compose/rememberFormica.kt new file mode 100644 index 0000000..4227d1d --- /dev/null +++ b/formica/src/commonMain/kotlin/dev/voir/formica/compose/rememberFormica.kt @@ -0,0 +1,26 @@ +package dev.voir.formica.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import dev.voir.formica.core.Formica +import dev.voir.formica.core.FormicaValidationAdapter + +/** + * Remember one Formica instance for the given adapter + initial data. + * + * This keeps the form runtime stable across recompositions. + */ +@Composable +fun rememberFormica( + adapter: FormicaValidationAdapter, + initialData: Data, + onSubmit: ((Data) -> Unit)? = null +): Formica { + return remember(adapter, initialData, onSubmit) { + Formica( + adapter = adapter, + initialData = initialData, + onSubmit = onSubmit + ) + } +} diff --git a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt index 3ea6f9e..12f2624 100644 --- a/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt +++ b/formica/src/commonMain/kotlin/dev/voir/formica/schema/SchemaDslExtensions.kt @@ -130,77 +130,77 @@ fun SchemaFieldBuilder.range( } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.min( +fun SchemaFieldBuilder.min( min: Int, inclusive: Boolean = true, message: (Int) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } ) { - rule(ValidationRules.min(min, inclusive, message) as ValidationRule) + rule(ValidationRules.min(min, inclusive, message)) } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.max( +fun SchemaFieldBuilder.max( max: Int, inclusive: Boolean = true, message: (Int) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } ) { - rule(ValidationRules.max(max, inclusive, message) as ValidationRule) + rule(ValidationRules.max(max, inclusive, message)) } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.min( +fun SchemaFieldBuilder.min( min: Long, inclusive: Boolean = true, message: (Long) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } ) { - rule(ValidationRules.min(min, inclusive, message) as ValidationRule) + rule(ValidationRules.min(min, inclusive, message)) } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.max( +fun SchemaFieldBuilder.max( max: Long, inclusive: Boolean = true, message: (Long) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } ) { - rule(ValidationRules.max(max, inclusive, message) as ValidationRule) + rule(ValidationRules.max(max, inclusive, message)) } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.min( +fun SchemaFieldBuilder.min( min: Double, inclusive: Boolean = true, epsilon: Double = 0.0, message: (Double) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } ) { - rule(ValidationRules.min(min, inclusive, epsilon, message) as ValidationRule) + rule(ValidationRules.min(min, inclusive, epsilon, message)) } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.max( +fun SchemaFieldBuilder.max( max: Double, inclusive: Boolean = true, epsilon: Double = 0.0, message: (Double) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } ) { - rule(ValidationRules.max(max, inclusive, epsilon, message) as ValidationRule) + rule(ValidationRules.max(max, inclusive, epsilon, message)) } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.min( +fun SchemaFieldBuilder.min( min: Float, inclusive: Boolean = true, epsilon: Float = 0f, message: (Float) -> String = { "Must be ${if (inclusive) ">=" else ">"} $it." } ) { - rule(ValidationRules.min(min, inclusive, epsilon, message) as ValidationRule) + rule(ValidationRules.min(min, inclusive, epsilon, message)) } @Suppress("UNCHECKED_CAST") -fun SchemaFieldBuilder.max( +fun SchemaFieldBuilder.max( max: Float, inclusive: Boolean = true, epsilon: Float = 0f, message: (Float) -> String = { "Must be ${if (inclusive) "<=" else "<"} $it." } ) { - rule(ValidationRules.max(max, inclusive, epsilon, message) as ValidationRule) + rule(ValidationRules.max(max, inclusive, epsilon, message)) } diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt index ca53438..99f4a03 100644 --- a/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt +++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/App.kt @@ -1,314 +1,8 @@ package dev.voir.formica.sample -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.Button -import androidx.compose.material.Checkbox -import androidx.compose.material.Text -import androidx.compose.material.TextField import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import dev.voir.formica.FormicaFieldId -import dev.voir.formica.FormicaFieldResult -import dev.voir.formica.FormicaResult -import dev.voir.formica.ValidationRule -import dev.voir.formica.ValidationRules -import dev.voir.formica.sample.ui.FormFieldWrapper -import dev.voir.formica.ui.FormFieldPresence -import dev.voir.formica.ui.FormicaField -import dev.voir.formica.ui.FormicaProvider -import dev.voir.formica.ui.rememberFormica -import dev.voir.formica.ui.rememberFormicaFieldValue - -data class FormSchema( - var text: String, - var numberRequired: Int, - var numberOptional: Int?, - var optionalText: String?, - var activateAdditionalText: Boolean, - var additionalText: String? = null, -) - -val MainText = FormicaFieldId( - id = "text", - get = { it.text }, - set = { d, v -> d.copy(text = v) } -) -val NumberRequired = FormicaFieldId( - id = "numberRequired", - get = { it.numberRequired }, - set = { d, v -> d.copy(numberRequired = v) } -) - -val NumberOptional = FormicaFieldId( - id = "numberOptional", - get = { it.numberOptional }, - set = { d, v -> d.copy(numberOptional = v) } -) - -val OptionalText = FormicaFieldId( - id = "optionalText", - get = { it.optionalText }, - set = { d, v -> d.copy(optionalText = v) } -) - -val ActivateAdditionalText = FormicaFieldId( - id = "activateAdditionalText", - get = { it.activateAdditionalText }, - set = { d, v -> d.copy(activateAdditionalText = v) } -) - -val AdditionalText = FormicaFieldId( - id = "additionalText", - get = { it.additionalText }, - set = { d, v -> d.copy(additionalText = v) }, - clear = { d -> d.copy(additionalText = null) } -) - @Composable fun App() { - val verticalScroll = rememberScrollState() - - val formica = rememberFormica( - initialData = FormSchema( - text = "", - numberRequired = 0, - numberOptional = null, - optionalText = null, - activateAdditionalText = false, - additionalText = null, - ) - ) - - var formError by remember { mutableStateOf(null) } - var formResult by remember { mutableStateOf(null) } - - val isActive = rememberFormicaFieldValue(formica, ActivateAdditionalText) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 48.dp) - .verticalScroll(verticalScroll) - ) { - FormicaProvider(formica) { - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier.fillMaxWidth() - ) { - FormicaField( - id = MainText, - validators = setOf(ValidationRules.required()) - ) { field -> - FormFieldWrapper { - TextField( - modifier = Modifier.fillMaxWidth(), - value = field.value.orEmpty(), - label = { - Text("Required text") - }, - placeholder = { - Text("Some required text") - }, - onValueChange = { - field.onChange(it) - }, - ) - - if (field.error != null) { - Text(field.error!!, color = Color.Red) - } - } - } - - FormicaField(id = OptionalText) { field -> - FormFieldWrapper { - TextField( - modifier = Modifier.fillMaxWidth(), - value = field.value.orEmpty(), - label = { - Text("Optional text") - }, - placeholder = { - Text("Some optional text") - }, - onValueChange = { - field.onChange(it) - }, - ) - - if (field.error != null) { - Text(field.error!!, color = Color.Red) - } - } - } - - // Number (required and must be >= 10 if provided) - FormicaField( - id = NumberRequired, - validators = setOf( - ValidationRules.required(), - ValidationRules.min(10) - ) - ) { field -> - FormFieldWrapper { - TextField( - modifier = Modifier.fillMaxWidth(), - value = field.value?.toString().orEmpty(), - label = { - Text("Number (required and >= 10)") - }, - placeholder = { - Text("1234") - }, - onValueChange = { s -> - field.onChange(s.toIntOrNull()) - } - ) - - if (field.error != null) { - Text(field.error!!, color = Color.Red) - } - } - } - - // Number (optional, but must be >= 0 if provided) - FormicaField( - id = NumberOptional, - validators = setOf( - ValidationRule { v -> - if (v == null) FormicaFieldResult.Success - else if (v < 0) FormicaFieldResult.Error("Number must be non-negative") - else FormicaFieldResult.Success - } - ) - ) { field -> - FormFieldWrapper { - TextField( - modifier = Modifier.fillMaxWidth(), - value = field.value?.toString().orEmpty(), - label = { - Text("Number (optional, but >= 0 if provided)") - }, - placeholder = { - Text("1234") - }, - onValueChange = { s -> - field.onChange(s.toIntOrNull()) - } - ) - - if (field.error != null) { - Text(field.error!!, color = Color.Red) - } - } - } - - FormicaField(id = ActivateAdditionalText) { field -> - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Checkbox( - checked = field.value ?: false, - onCheckedChange = { - field.onChange(it) - }) - Text("Activate additional text?") - } - } - - FormicaField( - id = AdditionalText, - customValidation = { value -> - if (value.isNullOrBlank()) { - FormicaFieldResult.Error(message = "Field is required") - } else { - FormicaFieldResult.Success - } - } - ) { field -> - FormFieldPresence( - form = formica, - id = AdditionalText, - present = isActive == true, - clearOnHide = true - ) { - FormFieldWrapper { - TextField( - modifier = Modifier.fillMaxWidth(), - value = field.value.orEmpty(), - label = { - Text("Required text if checkbox activated") - }, - placeholder = { - Text("Some additional text") - }, - onValueChange = { - field.onChange(it) - }, - ) - - if (field.error != null) { - Text(field.error!!, color = Color.Red) - } - } - } - } - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - // Reset result and error - formResult = null - formError = null - - val state = formica.validate() - if (state is FormicaResult.Valid) { - formError = null - formResult = formica.data.value - } else if (state is FormicaResult.Error) { - formError = state - formResult = null - } - }) { - Text("Submit") - } - - formError?.let { - Column(modifier = Modifier.fillMaxWidth()) { - Text("Form submit error") - Text(text = it.message, color = Color.Red) - Text(text = it.fieldErrors.toString(), color = Color.Red) - } - } - - formResult?.let { - Column(modifier = Modifier.fillMaxWidth()) { - Text("Form submit result") - - Text(text = it.toString()) - } - } - } + DemoScreen() } diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFields.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFields.kt new file mode 100644 index 0000000..2b78ed4 --- /dev/null +++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFields.kt @@ -0,0 +1,128 @@ +package dev.voir.formica.sample + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.Checkbox +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.voir.formica.compose.FormicaFieldController + +@Composable +fun FormicaStringField( + label: String, + field: FormicaFieldController, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + OutlinedTextField( + value = field.value ?: "", + onValueChange = field.onChange, + isError = field.showError, + enabled = field.enabled, + label = { Text(label) } + ) + + if (field.showError && field.error != null) { + Spacer(Modifier.height(4.dp)) + Text(field.error!!) + } + } +} + +@Composable +fun FormicaNullableStringField( + label: String, + field: FormicaFieldController, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + OutlinedTextField( + value = field.value.orEmpty(), + onValueChange = field.onChange, + isError = field.showError, + enabled = field.enabled, + label = { Text(label) } + ) + + if (field.showError && field.error != null) { + Spacer(Modifier.height(4.dp)) + Text(field.error!!) + } + } +} + +@Composable +fun FormicaIntField( + label: String, + field: FormicaFieldController, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + OutlinedTextField( + value = field.value?.toString().orEmpty(), + onValueChange = { input -> + field.onChange(input.toIntOrNull()) + }, + isError = field.showError, + enabled = field.enabled, + label = { Text(label) } + ) + + if (field.showError && field.error != null) { + Spacer(Modifier.height(4.dp)) + Text(field.error!!) + } + } +} + +@Composable +fun FormicaDoubleField( + label: String, + field: FormicaFieldController, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + OutlinedTextField( + value = field.value?.toString().orEmpty(), + onValueChange = { input -> + field.onChange(input.toDoubleOrNull()) + }, + isError = field.showError, + enabled = field.enabled, + label = { Text(label) } + ) + + if (field.showError && field.error != null) { + Spacer(Modifier.height(4.dp)) + Text(field.error!!) + } + } +} + +@Composable +fun FormicaCheckboxField( + label: String, + field: FormicaFieldController, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + Row { + Checkbox( + checked = field.value ?: false, + onCheckedChange = field.onChange, + enabled = field.enabled + ) + Text(label) + } + + if (field.showError && field.error != null) { + Spacer(Modifier.height(4.dp)) + Text(field.error!!) + } + } +} diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormData.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormData.kt new file mode 100644 index 0000000..1cb694a --- /dev/null +++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormData.kt @@ -0,0 +1,15 @@ +package dev.voir.formica.sample + +data class DemoFormData( + val email: String = "", + val name: String = "", + val nickname: String? = null, + val password: String = "", + val website: String? = null, + val age: Int? = null, + val score: Double? = null, + val acceptedTerms: Boolean = false, + val hasSecondaryAddress: Boolean = false, + val secondaryAddress: String? = null, + val manualValidationField: String = "" +) diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormSchema.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormSchema.kt new file mode 100644 index 0000000..b398eed --- /dev/null +++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoFormSchema.kt @@ -0,0 +1,122 @@ +package dev.voir.formica.sample + +import dev.voir.formica.schema.* + +val DemoFormSchema = schema { + field( + property = DemoFormData::email, + set = { data, value -> data.copy(email = value ?: "") } + ) { + required("Email is required") + email("Email is invalid") + } + + field( + property = DemoFormData::name, + set = { data, value -> data.copy(name = value ?: "") } + ) { + notBlank("Name is required") + minLength(2, "Name must have at least 2 characters") + maxLength(50, "Name must have at most 50 characters") + } + + field( + property = DemoFormData::nickname, + set = { data, value -> data.copy(nickname = value) }, + clear = { data -> data.copy(nickname = null) } + ) { + notEmpty("Nickname cannot be empty if provided") + maxLength(20, "Nickname must have at most 20 characters") + } + + field( + property = DemoFormData::password, + set = { data, value -> data.copy(password = value ?: "") } + ) { + required("Password is required") + strongPassword() + } + + field( + property = DemoFormData::website, + set = { data, value -> data.copy(website = value) }, + clear = { data -> data.copy(website = null) } + ) { + url( + protocolRequired = true, + message = "Website must be a valid URL with http:// or https://" + ) + } + + field( + property = DemoFormData::age, + set = { data, value -> data.copy(age = value) }, + clear = { data -> data.copy(age = null) } + ) { + min(18, message = { "Age must be at least $it" }) + max(120, message = { "Age must be at most $it" }) + } + + field( + property = DemoFormData::score, + set = { data, value -> data.copy(score = value) }, + clear = { data -> data.copy(score = null) } + ) { + range( + min = 0.0, + max = 100.0, + inclusive = true, + message = { lo, hi -> "Score must be between $lo and $hi" } + ) + } + + field( + property = DemoFormData::acceptedTerms, + set = { data, value -> data.copy(acceptedTerms = value ?: false) } + ) { + checked("You must accept terms") + } + + field( + property = DemoFormData::hasSecondaryAddress, + set = { data, value -> data.copy(hasSecondaryAddress = value ?: false) } + ) + + field( + property = DemoFormData::secondaryAddress, + set = { data, value -> data.copy(secondaryAddress = value) }, + clear = { data -> data.copy(secondaryAddress = null) } + ) { + visibleWhen { it.hasSecondaryAddress } + enabledWhen { it.hasSecondaryAddress } + notBlank("Secondary address is required when enabled") + minLength(5, "Secondary address must have at least 5 characters") + } + + field( + property = DemoFormData::manualValidationField, + set = { data, value -> data.copy(manualValidationField = value ?: "") } + ) { + validateOnChange(false) + notBlank("This field is validated only when requested") + minLength(4, "Manual field must have at least 4 characters") + } + + objectRule { data -> + val fieldErrors = mutableMapOf() + val formErrors = mutableListOf() + + if (data.name.isNotBlank() && data.email.isNotBlank() && data.name == data.email) { + fieldErrors["name"] = "Name must not be the same as email" + } + + if (data.age != null && data.score != null && data.age < 21 && data.score > 90.0) { + formErrors += "Users under 21 cannot have score above 90 in this demo rule" + } + + ObjectRuleResult( + fieldErrors = fieldErrors, + formErrors = formErrors + ) + } +} diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoScreen.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoScreen.kt new file mode 100644 index 0000000..9553fda --- /dev/null +++ b/sample/src/commonMain/kotlin/dev/voir/formica/sample/DemoScreen.kt @@ -0,0 +1,279 @@ +package dev.voir.formica.sample + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Divider +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.voir.formica.compose.Formica +import dev.voir.formica.compose.rememberFormica + +@Composable +fun DemoScreen() { + val initialData = remember { + DemoFormData( + email = "", + name = "", + nickname = null, + password = "", + website = null, + age = null, + score = null, + acceptedTerms = false, + hasSecondaryAddress = false, + secondaryAddress = null, + manualValidationField = "" + ) + } + + val formica = rememberFormica( + adapter = DemoFormSchema, + initialData = initialData + ) + + Formica(formica) { form -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text("Formica demo") + + Divider() + + Text("Required + email") + Field(DemoFormData::email) { field -> + FormicaStringField( + label = "Email", + field = field + ) + } + + Text("Required + min/max length") + Field(DemoFormData::name) { field -> + FormicaStringField( + label = "Name", + field = field + ) + } + + Text("Optional field: null is allowed, empty string is not") + Field(DemoFormData::nickname) { field -> + FormicaNullableStringField( + label = "Nickname (optional)", + field = field + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { formica.clear(DemoFormData::nickname) } + ) { + Text("Clear nickname") + } + } + + Divider() + + Text("Strong password") + Field(DemoFormData::password) { field -> + FormicaStringField( + label = "Password", + field = field + ) + } + + Text("Optional URL with protocol required") + Field(DemoFormData::website) { field -> + FormicaNullableStringField( + label = "Website", + field = field + ) + } + + Divider() + + Text("Int min/max") + Field(DemoFormData::age) { field -> + FormicaIntField( + label = "Age", + field = field + ) + } + + Text("Double range") + Field(DemoFormData::score) { field -> + FormicaDoubleField( + label = "Score", + field = field + ) + } + + Divider() + + Text("Checkbox with checked() validation") + Field(DemoFormData::acceptedTerms) { field -> + FormicaCheckboxField( + label = "Accept terms", + field = field + ) + } + + Divider() + + Text("Conditional field visibility / enabled state") + Field(DemoFormData::hasSecondaryAddress) { field -> + FormicaCheckboxField( + label = "Provide secondary address", + field = field + ) + } + + Field(DemoFormData::secondaryAddress) { field -> + FormicaNullableStringField( + label = "Secondary address", + field = field + ) + } + + Divider() + + Text("Manual validation field") + Field(DemoFormData::manualValidationField) { field -> + FormicaStringField( + label = "Manual validation field", + field = field + ) + + Spacer(Modifier.height(8.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + onClick = { + field.validate() + }, + enabled = field.enabled + ) { + Text("Validate this field") + } + + Button( + onClick = field.clear, + enabled = field.enabled + ) { + Text("Clear this field") + } + } + } + + Divider() + + Text("Form actions") + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + form.validate() + } + ) { + Text("Validate form") + } + + Button( + onClick = { + form.submit() + }, + enabled = true + ) { + Text("Submit") + } + + Button( + onClick = form.reset + ) { + Text("Reset") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + form.replaceData( + DemoFormData( + email = "preset@example.com", + name = "Preset User", + nickname = "PresetNick", + password = "Password1!", + website = "https://example.com", + age = 30, + score = 88.5, + acceptedTerms = true, + hasSecondaryAddress = true, + secondaryAddress = "Preset secondary address", + manualValidationField = "abcd" + ) + ) + } + ) { + Text("Load preset") + } + + Button( + onClick = { + form.replaceData(DemoFormData()) + } + ) { + Text("Load empty") + } + } + + Divider() + + Text("Form state") + Text("isDirty = ${form.isDirty}") + Text("isTouched = ${form.isTouched}") + Text("hasErrors = ${form.hasErrors}") + Text("canSubmit = ${form.canSubmit}") + Text("submitResult = ${form.submitResult}") + + Divider() + + Text("Field errors") + if (form.fieldErrors.isEmpty()) { + Text("No field errors") + } else { + form.fieldErrors.forEach { (key, value) -> + Text("$key: $value") + } + } + + Divider() + + Text("Form errors") + if (form.formErrors.isEmpty()) { + Text("No form errors") + } else { + form.formErrors.forEach { error -> + Text(error) + } + } + + Divider() + + Text("Current data") + Text(form.data.toString()) + } + } +} diff --git a/sample/src/commonMain/kotlin/dev/voir/formica/sample/ui/FormFieldWrapper.kt b/sample/src/commonMain/kotlin/dev/voir/formica/sample/ui/FormFieldWrapper.kt deleted file mode 100644 index 8a492cf..0000000 --- a/sample/src/commonMain/kotlin/dev/voir/formica/sample/ui/FormFieldWrapper.kt +++ /dev/null @@ -1,14 +0,0 @@ -package dev.voir.formica.sample.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.unit.dp - -@Composable -fun FormFieldWrapper(content: @Composable ColumnScope.() -> Unit) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - content() - } -} From ac9d3ca0acde39aaf2e04a42db57c7403c47198c Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Fri, 17 Apr 2026 18:39:05 +0200 Subject: [PATCH 5/6] adjust pipeline --- .github/workflows/publish.yml | 165 +++++++++++++--- LICENSE | 2 +- README.md | 360 ++++++++++++++++++---------------- build.gradle.kts | 3 + formica/build.gradle.kts | 63 +++--- 5 files changed, 362 insertions(+), 231 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2e5fb65..d0a5487 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,57 +1,160 @@ -name: Publish library +name: Publish to Maven Central on: - release: - types: [ published ] workflow_dispatch: + inputs: + version: + description: "Optional version to publish (example: 1.1.0). If omitted, latest tag is used and minor is bumped." + required: false + type: string + release_name: + description: "Optional GitHub release title" + required: false + type: string + release_notes: + description: "Optional release notes" + required: false + type: string jobs: publish: - name: Publish package to Github Registry - runs-on: ubuntu-latest permissions: - contents: read - packages: write + contents: write steps: - - name: Checkout - uses: actions/checkout@v6 + - name: Check out repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 - - name: Set up JDK + - name: Set up Java uses: actions/setup-java@v5 with: distribution: temurin - java-version: 21 + java-version: "21" cache: gradle - - name: Derive version from release tag - id: ver + - name: Determine target version and tag + id: versioning shell: bash run: | - TAG="${{ github.event.release.tag_name || github.ref_name }}" - CLEAN="${TAG#v}" # strip leading v if present - echo "VERSION=$CLEAN" >> "$GITHUB_OUTPUT" - echo "Release version: $CLEAN" + set -euo pipefail - - name: Set root version (used by :proto via parent!!.version) - run: | - if [ -f gradle.properties ]; then - if grep -q '^version=' gradle.properties; then - sed -i "s/^version=.*/version=${{ steps.ver.outputs.VERSION }}/" gradle.properties + INPUT_VERSION="${{ inputs.version }}" + + if [[ -n "$INPUT_VERSION" ]]; then + TARGET_VERSION="$INPUT_VERSION" + echo "Using provided version: $TARGET_VERSION" + else + LATEST_TAG="$( + git tag -l 'v*' \ + | sed 's/^v//' \ + | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \ + | sort -V \ + | tail -n 1 + )" + + if [[ -z "$LATEST_TAG" ]]; then + TARGET_VERSION="1.0.0" + echo "No release tags found. Using default version: $TARGET_VERSION" else - echo "version=${{ steps.ver.outputs.VERSION }}" >> gradle.properties + echo "Latest release tag version: $LATEST_TAG" + + if [[ ! "$LATEST_TAG" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "Latest tag version '$LATEST_TAG' is not valid semver (x.y.z)" + exit 1 + fi + + MAJOR="${BASH_REMATCH[1]}" + MINOR="${BASH_REMATCH[2]}" + TARGET_VERSION="${MAJOR}.$((MINOR + 1)).0" + + echo "No version provided. Bumped minor from latest tag to: $TARGET_VERSION" fi - else - echo "version=${{ steps.ver.outputs.VERSION }}" > gradle.properties fi - echo "Using $(grep '^version=' gradle.properties)" - - name: Build library - run: ./gradlew :formica:clean :formica:build -x check + if [[ ! "$TARGET_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Target version '$TARGET_VERSION' is not valid semver (x.y.z)" + exit 1 + fi + + TARGET_TAG="v${TARGET_VERSION}" + + echo "Target version: $TARGET_VERSION" + echo "Target tag: $TARGET_TAG" + + if git ls-remote --tags origin "refs/tags/${TARGET_TAG}" | grep -q "refs/tags/${TARGET_TAG}$"; then + echo "Tag already exists on origin: $TARGET_TAG" + exit 1 + fi + + echo "target_version=$TARGET_VERSION" >> "$GITHUB_OUTPUT" + echo "target_tag=$TARGET_TAG" >> "$GITHUB_OUTPUT" + + - name: Verify Gradle sees target version + shell: bash + run: | + set -euo pipefail + + TARGET_VERSION="${{ steps.versioning.outputs.target_version }}" + PROJECT_VERSION="$(./gradlew -q properties -Pversion="$TARGET_VERSION" | awk -F': ' '/^version:/ {print $2; exit}')" + + echo "Gradle project version: $PROJECT_VERSION" + echo "Target version: $TARGET_VERSION" - - name: Publish library to GitHub Packages + if [[ "$PROJECT_VERSION" != "$TARGET_VERSION" ]]; then + echo "Gradle version does not match target version." + exit 1 + fi + + - name: Publish to Maven Central + run: ./gradlew publishToMavenCentral -Pversion=${{ steps.versioning.outputs.target_version }} --no-configuration-cache + env: + ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.CENTRAL_USERNAME }} + ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.CENTRAL_PASSWORD }} + ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.MAVEN_GPG_PRIVATE_KEY }} + ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.MAVEN_GPG_PASSPHRASE }} + + - name: Create and push git tag + shell: bash + run: | + set -euo pipefail + + TAG="${{ steps.versioning.outputs.target_tag }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Tag already exists locally: $TAG" + exit 1 + fi + + git tag -a "$TAG" -m "Release $TAG" + git push origin "$TAG" + + - name: Create GitHub release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: ./gradlew :formica:publish + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + set -euo pipefail + + TAG="${{ steps.versioning.outputs.target_tag }}" + RELEASE_NAME="${{ inputs.release_name }}" + + if [[ -z "$RELEASE_NAME" ]]; then + RELEASE_NAME="$TAG" + fi + + if [[ -n "${{ inputs.release_notes }}" ]]; then + gh release create "$TAG" \ + --title "$RELEASE_NAME" \ + --notes "${{ inputs.release_notes }}" + else + gh release create "$TAG" \ + --title "$RELEASE_NAME" \ + --generate-notes + fi diff --git a/LICENSE b/LICENSE index 153d416..0a04128 100644 --- a/LICENSE +++ b/LICENSE @@ -162,4 +162,4 @@ General Public License ever published by the Free Software Foundation. whether future versions of the GNU Lesser General Public License shall apply, that proxy's public statement of acceptance of any version is permanent authorization for you to choose that version for the -Library. \ No newline at end of file +Library. diff --git a/README.md b/README.md index 809a40f..734b392 100644 --- a/README.md +++ b/README.md @@ -1,242 +1,256 @@ # Formica -**Formica** is a **Kotlin Compose Multiplatform** library for managing **form state**, -**validation**, and **data binding** in a fully declarative way. -It eliminates boilerplate by giving you **reactive fields**, **built-in validators**, and -**lens-based binding** for immutable form models. +**Formica** is a lightweight, Kotlin Multiplatform-friendly form engine with: + +- Schema-based validation (no annotations, no reflection magic) +- Reactive form state (per-field + whole form) +- Compose-first API (but UI-agnostic core) +- Full control over validation behavior +- Immutable data support --- ## Features -- **Immutable form data model** — no reflection, no direct mutation. -- **Reactive fields** — each field tracks value, error, touched, dirty. -- **Composable API** — `FormicaField` for inline field binding, `FormicaFieldState` for external - field state. -- **Built-in validation rules** — or plug in your own. -- **Scoped provider** — share the form across nested composables without prop drilling. -- **Type-safe** — works with your data class directly. +- **Schema DSL** (clean, composable validation) +- **No annotations / no codegen** +- **Field-level + form-level validation** +- **Optional & conditional fields** +- **Reactive state (dirty, touched, errors, etc.)** +- **Compose integration** +- **Works in Kotlin Multiplatform** --- -## Installation +## Modules -Add the following dependency to your project: +```text +formica.core → runtime engine (state, validation, form logic) +formica.schema → schema DSL + validation rules +formica.compose → Compose integration layer +``` -```gradle -dependencies { - implementation("dev.voir.formica:1.0.0") -} +--- -maven { - url = uri("https://maven.pkg.github.com/VoirDev/formice") - credentials { - username = - password = - } -} +## Quick Start + +### 1. Define your model + +```kotlin +data class NewUser( + val email: String = "", + val name: String = "", + val address: String? = null, + val age: Int? = null, + val acceptedTerms: Boolean = false +) ``` +--- + +### 2. Create a schema + ```kotlin -val UserSchema = schema { - field(User::email) { +val NewUserSchema = schema { + + field( + property = NewUser::email, + set = { data, value -> data.copy(email = value ?: "") } + ) { required("Email is required") - email() + email("Invalid email") } - field(User::password) { - strongPassword() + field( + property = NewUser::name, + set = { data, value -> data.copy(name = value ?: "") } + ) { + notBlank("Name is required") + maxLength(50) } - field(User::age) { - min(18) - max(99) + field( + property = NewUser::address, + set = { data, value -> data.copy(address = value) }, + clear = { data -> data.copy(address = null) } + ) { + notBlank("Address cannot be blank if provided") } - field(User::website) { - url(protocolRequired = true) + field( + property = NewUser::age, + set = { data, value -> data.copy(age = value) }, + clear = { data -> data.copy(age = null) } + ) { + min(18) } - field(User::termsAccepted) { - checked() + field( + property = NewUser::acceptedTerms, + set = { data, value -> data.copy(acceptedTerms = value ?: false) } + ) { + checked("You must accept terms") } } ``` -## Getting started +--- -1. Define your form data schema +### 3. Use in Compose ```kotlin -data class FormSchema( - val firstName: String, - val lastName: String?, - val email: String, - val subscribe: Boolean -) +@Composable +fun NewUserScreen() { + val formica = rememberFormica( + adapter = NewUserSchema, + initialData = NewUser() + ) + + Formica(formica) { form -> + + Field(NewUser::email) { field -> + Column { + OutlinedTextField( + value = field.value ?: "", + onValueChange = field.onChange, + isError = field.showError + ) + + if (field.showError && field.error != null) { + Text(field.error) + } + } + } + + Button( + onClick = form.submit, + enabled = form.canSubmit + ) { + Text("Submit") + } + } +} ``` -2. Create Field IDs (lenses) +--- + +## Core Concepts -```kotlin -val FirstName = FormicaFieldId( - id = "firstName", - get = { it.firstName }, - set = { data, value -> data.copy(firstName = value) }, - clear = { data -> data.copy(firstName = "") } -) +### Schema-first validation -val LastName = FormicaFieldId( - id = "lastName", - get = { it.lastName }, - set = { data, value -> data.copy(lastName = value) }, - clear = { data -> data.copy(lastName = null) } -) +No annotations. No reflection scanning. -val Email = FormicaFieldId( - id = "email", - get = { it.email }, - set = { data, value -> data.copy(email = value) } -) +Validation is explicitly defined: -val Subscribe = FormicaFieldId( - id = "subscribe", - get = { it.subscribe }, - set = { data, value -> data.copy(subscribe = value) } -) +```kotlin +required() +email() +min(18) ``` -3. Provide the form +--- -```kotlin -@Composable -fun YourFormScreen() { - val form = rememberFormica( - initialData = FormSchema("", null, "", false), - onSubmit = { data -> - println("Form submitted: $data") - } - ) +### Reactive form state - FormicaProvider(form) { - YourFormContent() - } -} +Each field tracks: + +```kotlin +value +error +dirty +touched +visible +enabled ``` -4. Build form UI +The form tracks: ```kotlin -@Composable -fun YourFormContent() { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - - // Text field example - FormicaField( - id = FirstName, - validators = setOf( - ValidationRules.minLength(2, "First name must be at least 2 characters"), - ValidationRules.maxLength(64, "First name must not exceed 64 characters") - ) - ) { field -> - TextField( - value = field.value.orEmpty(), - onValueChange = field.onChange, - label = { Text("First Name") }, - isError = field.error != null - ) - field.error?.let { Text(it, color = Color.Red) } - } - - // Checkbox example - FormicaField(id = Subscribe) { field -> - Row(verticalAlignment = Alignment.CenterVertically) { - Checkbox( - checked = field.value ?: false, - onCheckedChange = field.onChange - ) - Text("Subscribe to newsletter") - } - } +isDirty +isTouched +hasErrors +canSubmit +fieldErrors +formErrors +submitResult +``` - // Conditional field - FormFieldPresence( - form = formicaOf(), - id = Subscribe, - present = formicaOf().data.value.subscribe - ) { - FormicaField( - id = Email, - validators = setOf( - ValidationRules.validateOnlyIf( - active = { formicaOf().data.value.subscribe }, - rule = ValidationRules.email() - ) - ) - ) { field -> - TextField( - value = field.value.orEmpty(), - onValueChange = field.onChange, - label = { Text("Email Address") }, - isError = field.error != null - ) - field.error?.let { Text(it, color = Color.Red) } - } - } +--- - Spacer(Modifier.height(16.dp)) +### Optional fields - Button(onClick = { - val result = formicaOf().submit() - if (result is FormicaResult.Valid) { - println("Form valid, submitting...") - } - }) { - Text("Submit") - } - } -} +```kotlin +val nickname: String? = null ``` -## How to? +Rules like `notBlank()`: -#### 1. Reading Field State Outside the Field +- skip if `null` +- validate if not null -Sometimes you want to react to a field’s state elsewhere in the UI: +--- -```kotlin -val subscribeState = rememberFormicaFieldState(Subscribe) +### Conditional fields -if (subscribeState?.value == true) { - Text("Thanks for subscribing!") -} +```kotlin +visibleWhen { it.hasSecondaryAddress } +enabledWhen { it.hasSecondaryAddress } ``` -#### 2. Use validation Rules +Hidden/disabled fields are automatically ignored during validation. + +--- -Formica ships with a set of ready-to-use rules: +### Manual validation ```kotlin -ValidationRules.required() -ValidationRules.email() -ValidationRules.minLength(3) -ValidationRules.maxLength(50) -ValidationRules.range(0, 100) -ValidationRules.checked() -ValidationRules.validateOnlyIf({ condition }, ValidationRules.required()) +validateOnChange(false) +field.validate() ``` -Or create your own: +--- + +### Object-level validation ```kotlin -val mustBeFoo = ValidationRule { v -> - if (v == "foo") FormicaFieldResult.Success - else FormicaFieldResult.Error("Must be 'foo'") +objectRule { data -> + if (data.name == data.email) { + ObjectRuleResult( + fieldErrors = mapOf("name" to "Name must not equal email") + ) + } else { + ObjectRuleResult() + } } ``` -## License +--- + +## Built-in Rules + +### Strings + +- notEmpty() +- notBlank() +- email() +- strongPassword() +- url() +- minLength() +- maxLength() + +### Numbers -This project is licensed under the GNU Lesser General Public License v3.0. +- min() +- max() +- range() -See the full license at https://www.gnu.org/licenses/lgpl-3.0.txt +### Boolean + +- checked() + +### Generic + +- required() +- validateOnlyIf() + +--- diff --git a/build.gradle.kts b/build.gradle.kts index b495ae2..60952d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,3 +6,6 @@ plugins { alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false } + +group = "dev.voir" +version = "1.0.1" diff --git a/formica/build.gradle.kts b/formica/build.gradle.kts index c4d6e72..13dd5d2 100644 --- a/formica/build.gradle.kts +++ b/formica/build.gradle.kts @@ -2,17 +2,11 @@ plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.compose.compiler) - id("maven-publish") + id("com.vanniktech.maven.publish") version "0.36.0" } -group = "dev.voir" -version = "0.0.1" - kotlin { - compilerOptions { - freeCompilerArgs.add("-opt-in=kotlin.time.ExperimentalTime") - freeCompilerArgs.add("-Xexpect-actual-classes") - } + jvmToolchain(21) jvm() iosX64() @@ -30,30 +24,47 @@ kotlin { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) } - jvmMain.dependencies { - } - iosMain.dependencies { - } + jvmMain.dependencies { } + iosMain.dependencies { } } } -publishing { - publications.withType().configureEach { - pom { - name.set("formica") - description.set("Formica – Kotlin Multiplatform form manager") - url.set("https://github.com/VoirDev/formica") +mavenPublishing { + publishToMavenCentral() + signAllPublications() + + coordinates( + groupId = "dev.voir", + artifactId = "formica", + version = project.version.toString() + ) + + pom { + name.set("Formica") + description.set("A Kotlin Multiplatform-friendly form engine") + url.set("https://github.com/VoirDev/formica/") + + licenses { + license { + name.set("GNU Lesser General Public License, Version 3") + url.set("https://www.gnu.org/licenses/lgpl-3.0.txt") + } } - } - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/VoirDev/formica") - credentials { - username = System.getenv("GITHUB_ACTOR") - password = System.getenv("GITHUB_TOKEN") + developers { + developer { + id.set("checksanity") + name.set("Gary Bezruchko") + email.set("hello@voir.dev") + organization.set("VOIR") + organizationUrl.set("https://voir.dev") } } + + scm { + url.set("https://github.com/VoirDev/formica/") + connection.set("scm:git:git://github.com/VoirDev/formica.git") + developerConnection.set("scm:git:ssh://git@github.com/VoirDev/formica.git") + } } } From 2f87da874f9a6664a8505ca7f4ae0ded973f4d15 Mon Sep 17 00:00:00 2001 From: Gary Bezruchko Date: Fri, 17 Apr 2026 22:04:40 +0200 Subject: [PATCH 6/6] fix local publish --- build.gradle.kts | 3 --- formica/build.gradle.kts | 12 +++++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 60952d6..b495ae2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,3 @@ plugins { alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false } - -group = "dev.voir" -version = "1.0.1" diff --git a/formica/build.gradle.kts b/formica/build.gradle.kts index 13dd5d2..98b667d 100644 --- a/formica/build.gradle.kts +++ b/formica/build.gradle.kts @@ -5,6 +5,9 @@ plugins { id("com.vanniktech.maven.publish") version "0.36.0" } +group = "dev.voir" +version = "1.0.0" + kotlin { jvmToolchain(21) @@ -29,9 +32,16 @@ kotlin { } } +val isLocalPublish = gradle.startParameter.taskNames.any { + it.contains("publishToMavenLocal", ignoreCase = true) +} + mavenPublishing { publishToMavenCentral() - signAllPublications() + + if (!isLocalPublish) { + signAllPublications() + } coordinates( groupId = "dev.voir",