diff --git a/plans/2026-02-05-form-validation.md b/plans/2026-02-05-form-validation.md new file mode 100644 index 0000000..fa173b0 --- /dev/null +++ b/plans/2026-02-05-form-validation.md @@ -0,0 +1,41 @@ +# Form Validation Support for uni-dom + +## Overview + +Add composable, reactive form validation that integrates naturally with the existing `value.bind()` / `checked.bind()` two-way binding system. Validation is purely Rx-based - no new DomNode types or DomRenderer changes needed. + +## API Design + +### Core Types + +- `ValidationState` - enum with `Valid` and `Invalid(messages)`, with `isValid` and `errors` accessors +- `ValidationRule[A]` - trait with `validate(A): ValidationState` +- `FieldValidation[A]` - reactive validation for a single field, derives `state`/`isValid`/`errors` via `source.map()` +- `FormValidation` - aggregates multiple field validations using chained `Rx.join` +- `Validate` object - factory methods + built-in validators + +### Built-in Validators + +- `required(message)` - non-empty string +- `minLength(n, message)` - minimum character count +- `maxLength(n, message)` - maximum character count +- `pattern(regex, message)` - regex matching +- `email(message)` - email format (simple practical regex) +- `rule[A](predicate, message)` - custom predicate +- `ruleWith[A](f)` - custom function returning ValidationState + +## Files + +| File | Description | +|------|-------------| +| `uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala` | All validation types and logic | +| `uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala` | 21 tests | +| `uni/.js/src/main/scala/wvlet/uni/dom/all.scala` | Added exports | + +## Key Design Decisions + +- Purely Rx-based: validation state is plain `Rx` values consumed by existing reactive DOM binding +- Validation runs on every change by default (reacts to all updates from `value.bind()`) +- FormValidation uses chained pairwise `Rx.join` via `foldLeft` to support any number of fields +- Tracks last value internally for imperative `validateNow()`/`validateAll()`; falls back to reading `RxVar.get` directly when the reactive chain has not been subscribed to +- Pattern follows Storage.scala: self-contained module, no DomNode extension needed diff --git a/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala b/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala new file mode 100644 index 0000000..256f267 --- /dev/null +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala @@ -0,0 +1,314 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import wvlet.uni.test.UniTest +import wvlet.uni.dom.all.* +import wvlet.uni.dom.all.given +import wvlet.uni.rx.Rx + +class ValidateTest extends UniTest: + + // --- Individual validators --- + + test("required rejects empty string"): + val rule = Validate.required("Name is required") + val result = rule.validate("") + result.isValid shouldBe false + result.errors shouldBe Seq("Name is required") + + test("required accepts non-empty string"): + val rule = Validate.required() + val result = rule.validate("hello") + result.isValid shouldBe true + result.errors shouldBe Seq.empty + + test("minLength rejects short string"): + val rule = Validate.minLength(3, "Too short") + val result = rule.validate("ab") + result.isValid shouldBe false + result.errors shouldBe Seq("Too short") + + test("minLength accepts string of exact length"): + val rule = Validate.minLength(3) + val result = rule.validate("abc") + result.isValid shouldBe true + + test("maxLength rejects long string"): + val rule = Validate.maxLength(5, "Too long") + val result = rule.validate("abcdef") + result.isValid shouldBe false + result.errors shouldBe Seq("Too long") + + test("maxLength accepts string within limit"): + val rule = Validate.maxLength(5) + val result = rule.validate("abc") + result.isValid shouldBe true + + test("pattern validates against regex"): + val rule = Validate.pattern("[a-z]+", "Only lowercase") + rule.validate("hello").isValid shouldBe true + rule.validate("Hello").isValid shouldBe false + rule.validate("123").isValid shouldBe false + + test("email validates email format"): + val rule = Validate.email() + rule.validate("user@example.com").isValid shouldBe true + rule.validate("user@sub.example.com").isValid shouldBe true + rule.validate("not-an-email").isValid shouldBe false + rule.validate("@missing-local.com").isValid shouldBe false + rule.validate("missing-domain@").isValid shouldBe false + rule.validate("has spaces@example.com").isValid shouldBe false + + test("custom rule with predicate"): + val rule = Validate.rule[Int](_ > 0, "Must be positive") + rule.validate(5).isValid shouldBe true + rule.validate(0).isValid shouldBe false + rule.validate(-1).isValid shouldBe false + + test("custom ruleWith returns ValidationState directly"): + val rule = Validate.ruleWith[Int] { v => + if v >= 0 && v <= 100 then + ValidationState.Valid + else + ValidationState.Invalid(Seq("Must be 0-100")) + } + rule.validate(50).isValid shouldBe true + rule.validate(101).isValid shouldBe false + + // --- ValidationState --- + + test("ValidationState.combine merges multiple states"): + val combined = ValidationState.combine( + Seq( + ValidationState.Valid, + ValidationState.Invalid(Seq("error1")), + ValidationState.Valid, + ValidationState.Invalid(Seq("error2", "error3")) + ) + ) + combined.isValid shouldBe false + combined.errors shouldBe Seq("error1", "error2", "error3") + + test("ValidationState.combine returns Valid when all valid"): + val combined = ValidationState.combine(Seq(ValidationState.Valid, ValidationState.Valid)) + combined.isValid shouldBe true + combined.errors shouldBe Seq.empty + + // --- FieldValidation --- + + test("FieldValidation reacts to RxVar changes"): + val name = Rx.variable("") + val nameV = Validate(name)(Validate.required("Required")) + + var currentState: ValidationState = ValidationState.Valid + val cancelable = nameV + .state + .run { s => + currentState = s + } + + // Initial empty value should be invalid + currentState.isValid shouldBe false + + name := "hello" + currentState.isValid shouldBe true + + name := "" + currentState.isValid shouldBe false + + cancelable.cancel + + test("FieldValidation with multiple rules collects all errors"): + val input = Rx.variable("") + val inputV = Validate(input)(Validate.required("Required"), Validate.minLength(3, "Too short")) + + var currentErrors: Seq[String] = Seq.empty + val cancelable = inputV + .errors + .run { e => + currentErrors = e + } + + // Empty string fails both rules + currentErrors shouldBe Seq("Required", "Too short") + + // Short but non-empty string fails minLength only + input := "ab" + currentErrors shouldBe Seq("Too short") + + // Valid string + input := "abc" + currentErrors shouldBe Seq.empty + + cancelable.cancel + + test("FieldValidation.isValid reflects current state"): + val value = Rx.variable(0) + val valueV = Validate(value)(Validate.rule(_ > 0, "Must be positive")) + + var valid = true + val cancelable = valueV + .isValid + .run { v => + valid = v + } + + valid shouldBe false + + value := 5 + valid shouldBe true + + value := -1 + valid shouldBe false + + cancelable.cancel + + test("FieldValidation.validateNow returns current validity"): + val input = Rx.variable("hello") + val inputV = Validate(input)(Validate.required()) + + // Subscribe to activate the reactive chain + val cancelable = inputV + .state + .run { _ => + () + } + + inputV.validateNow().isValid shouldBe true + + input := "" + inputV.validateNow().isValid shouldBe false + + cancelable.cancel + + // --- FormValidation --- + + test("FormValidation.isValid combines all fields"): + val name = Rx.variable("Alice") + val email = Rx.variable("alice@example.com") + + val nameV = Validate(name)(Validate.required()) + val emailV = Validate(email)(Validate.required(), Validate.email()) + val formV = Validate.form(nameV, emailV) + + var formValid = false + val cancelable = formV + .isValid + .run { v => + formValid = v + } + + formValid shouldBe true + + name := "" + formValid shouldBe false + + name := "Bob" + formValid shouldBe true + + email := "not-email" + formValid shouldBe false + + cancelable.cancel + + test("FormValidation.errors aggregates all field errors"): + val name = Rx.variable("") + val email = Rx.variable("") + + val nameV = Validate(name)(Validate.required("Name required")) + val emailV = Validate(email)(Validate.required("Email required")) + val formV = Validate.form(nameV, emailV) + + var allErrors: Seq[String] = Seq.empty + val cancelable = formV + .errors + .run { e => + allErrors = e + } + + allErrors shouldContain "Name required" + allErrors shouldContain "Email required" + + name := "Alice" + allErrors shouldBe Seq("Email required") + + email := "alice@example.com" + allErrors shouldBe Seq.empty + + cancelable.cancel + + test("FormValidation.validateAll returns current validity"): + val name = Rx.variable("Alice") + val email = Rx.variable("alice@example.com") + + val nameV = Validate(name)(Validate.required()) + val emailV = Validate(email)(Validate.required(), Validate.email()) + val formV = Validate.form(nameV, emailV) + + // Subscribe to activate the reactive chain + val c1 = nameV + .state + .run { _ => + () + } + val c2 = emailV + .state + .run { _ => + () + } + + formV.validateAll() shouldBe true + + name := "" + formV.validateAll() shouldBe false + + c1.cancel + c2.cancel + + test("FormValidation with empty fields list"): + val formV = Validate.form() + + var valid = false + val cancelable = formV + .isValid + .run { v => + valid = v + } + + valid shouldBe true + formV.validateAll() shouldBe true + + cancelable.cancel + + test("FormValidation with single field"): + val input = Rx.variable("test") + val inputV = Validate(input)(Validate.required()) + val formV = Validate.form(inputV) + + var valid = false + val cancelable = formV + .isValid + .run { v => + valid = v + } + + valid shouldBe true + + input := "" + valid shouldBe false + + cancelable.cancel + +end ValidateTest diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala new file mode 100644 index 0000000..bff8c98 --- /dev/null +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala @@ -0,0 +1,239 @@ +/* + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package wvlet.uni.dom + +import wvlet.uni.rx.{Cancelable, Rx, RxVar} + +/** + * Validation result for a field or form. + */ +enum ValidationState: + case Valid + case Invalid(messages: Seq[String]) + + def isValid: Boolean = + this match + case Valid => + true + case Invalid(_) => + false + + def errors: Seq[String] = + this match + case Valid => + Seq.empty + case Invalid(messages) => + messages + +object ValidationState: + /** + * Combine multiple validation states into one. If all are valid, returns Valid. Otherwise, + * returns Invalid with all errors collected. + */ + def combine(states: Seq[ValidationState]): ValidationState = + val allErrors = states.flatMap(_.errors) + if allErrors.isEmpty then + Valid + else + Invalid(allErrors) + +/** + * A single validation rule that checks a value of type A. + */ +trait ValidationRule[A]: + def validate(value: A): ValidationState + +/** + * Reactive validation for a single field. + * + * Validates the source value whenever it changes and exposes the validation state as reactive + * values. + * + * @param source + * The reactive value to validate + * @param rules + * The validation rules to apply + */ +class FieldValidation[A](source: Rx[A], rules: Seq[ValidationRule[A]]): + // Track the last value for imperative validation + @volatile + private var lastValue: Option[A] = None + + private def runRules(value: A): ValidationState = + lastValue = Some(value) + ValidationState.combine(rules.map(_.validate(value))) + + val state: Rx[ValidationState] = source.map(runRules) + val isValid: Rx[Boolean] = state.map(_.isValid) + val errors: Rx[Seq[String]] = state.map(_.errors) + + /** + * Imperatively check the current value against all rules. + */ + def validateNow(): ValidationState = + lastValue match + case Some(v) => + runRules(v) + case None => + ValidationState.Invalid(Seq("No value available")) + +/** + * Aggregate validation for multiple fields. + * + * Combines the validation state of all fields into a single reactive result. + * + * @param fields + * The field validations to aggregate + */ +class FormValidation(fields: Seq[FieldValidation[?]]): + val isValid: Rx[Boolean] = + fields.size match + case 0 => + Rx.variable(true) + case 1 => + fields(0).isValid + case n => + // Chain pairwise joins for any number of fields + val first: Rx[Boolean] = fields(0).isValid + fields + .tail + .foldLeft(first) { (acc, field) => + acc + .join(field.isValid) + .map { case (a, b) => + a && b + } + } + + val errors: Rx[Seq[String]] = + fields.size match + case 0 => + Rx.variable(Seq.empty) + case 1 => + fields(0).errors + case n => + val first: Rx[Seq[String]] = fields(0).errors + fields + .tail + .foldLeft(first) { (acc, field) => + acc + .join(field.errors) + .map { case (a, b) => + a ++ b + } + } + + /** + * Imperatively check all fields and return whether the form is valid. + */ + def validateAll(): Boolean = fields.forall(_.validateNow().isValid) + +end FormValidation + +/** + * Entry point for creating field and form validations, plus built-in validators. + * + * Usage: + * {{{ + * val username = Rx.variable("") + * val usernameV = Validate(username)( + * Validate.required("Username is required"), + * Validate.minLength(3, "At least 3 characters") + * ) + * + * val emailV = Validate(email)( + * Validate.required(), + * Validate.email() + * ) + * + * val formV = Validate.form(usernameV, emailV) + * }}} + */ +object Validate: + + /** + * Create a field validation for a reactive source with the given rules. + */ + def apply[A](source: Rx[A])(rules: ValidationRule[A]*): FieldValidation[A] = FieldValidation( + source, + rules.toSeq + ) + + /** + * Create a form-level validation that aggregates multiple field validations. + */ + def form(fields: FieldValidation[?]*): FormValidation = FormValidation(fields.toSeq) + + /** + * Validates that a string is non-empty. + */ + def required(message: String = "Required"): ValidationRule[String] = rule(_.nonEmpty, message) + + /** + * Validates that a string has at least `n` characters. + */ + def minLength(n: Int, message: String = ""): ValidationRule[String] = + val msg = + if message.nonEmpty then + message + else + s"Must be at least ${n} characters" + rule(_.length >= n, msg) + + /** + * Validates that a string has at most `n` characters. + */ + def maxLength(n: Int, message: String = ""): ValidationRule[String] = + val msg = + if message.nonEmpty then + message + else + s"Must be at most ${n} characters" + rule(_.length <= n, msg) + + /** + * Validates that a string matches a regex pattern. + */ + def pattern(regex: String, message: String = ""): ValidationRule[String] = + val msg = + if message.nonEmpty then + message + else + s"Must match pattern ${regex}" + rule(_.matches(regex), msg) + + /** + * Validates that a string looks like an email address. + */ + def email(message: String = "Invalid email address"): ValidationRule[String] = pattern( + "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$", + message + ) + + /** + * Create a validation rule from a predicate. + */ + def rule[A](predicate: A => Boolean, message: String): ValidationRule[A] = + (value: A) => + if predicate(value) then + ValidationState.Valid + else + ValidationState.Invalid(Seq(message)) + + /** + * Create a validation rule from a function that returns a ValidationState. + */ + def ruleWith[A](f: A => ValidationState): ValidationRule[A] = (value: A) => f(value) + +end Validate diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala index ed90f64..69232b2 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala @@ -134,6 +134,13 @@ object all extends HtmlTags with HtmlAttrs with SvgTags with SvgAttrs: export wvlet.uni.dom.GeoError export wvlet.uni.dom.GeoOptions + // Form validation + export wvlet.uni.dom.Validate + export wvlet.uni.dom.ValidationState + export wvlet.uni.dom.ValidationRule + export wvlet.uni.dom.FieldValidation + export wvlet.uni.dom.FormValidation + /** * Re-export helper functions. */