From 615e0fcebff0f286c25705280e84212e1fb83f11 Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 13:35:39 -0800 Subject: [PATCH 1/4] feature: Add Form Validation support to uni-dom Composable, reactive form validation that integrates with the existing value.bind() two-way binding system. Purely Rx-based with no DomNode or DomRenderer changes needed. Co-Authored-By: Claude Opus 4.6 --- plans/2026-02-05-form-validation.md | 41 +++ .../scala/wvlet/uni/dom/ValidateTest.scala | 314 ++++++++++++++++++ .../main/scala/wvlet/uni/dom/Validate.scala | 239 +++++++++++++ .../src/main/scala/wvlet/uni/dom/all.scala | 7 + 4 files changed, 601 insertions(+) create mode 100644 plans/2026-02-05-form-validation.md create mode 100644 uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala create mode 100644 uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala diff --git a/plans/2026-02-05-form-validation.md b/plans/2026-02-05-form-validation.md new file mode 100644 index 00000000..5952bd86 --- /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()` +- 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 00000000..256f267a --- /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 00000000..bff8c98a --- /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 c65438b8..8c63b733 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/all.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/all.scala @@ -128,6 +128,13 @@ object all extends HtmlTags with HtmlAttrs with SvgTags with SvgAttrs: export wvlet.uni.dom.DragData export wvlet.uni.dom.DragState + // 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. */ From b458ea0c5cb0aa948a38ade98e42095c9fba4ef3 Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 17:31:52 -0800 Subject: [PATCH 2/4] fix: Address review feedback for form validation Remove unused imports, drop @volatile (single-threaded Scala.js), add scaladoc for pattern's full-match behavior, and add test for validateNow() before subscription. Co-Authored-By: Claude Opus 4.6 --- .../src/test/scala/wvlet/uni/dom/ValidateTest.scala | 9 +++++++++ uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) 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 index 256f267a..7c1cfaf8 100644 --- a/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala @@ -193,6 +193,15 @@ class ValidateTest extends UniTest: cancelable.cancel + test("FieldValidation.validateNow returns Invalid before subscription"): + val input = Rx.variable("hello") + val inputV = Validate(input)(Validate.required()) + + // Without subscribing, lastValue is None + val result = inputV.validateNow() + result.isValid shouldBe false + result.errors shouldBe Seq("No value available") + // --- FormValidation --- test("FormValidation.isValid combines all fields"): diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala index bff8c98a..550b668d 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala @@ -13,7 +13,7 @@ */ package wvlet.uni.dom -import wvlet.uni.rx.{Cancelable, Rx, RxVar} +import wvlet.uni.rx.Rx /** * Validation result for a field or form. @@ -67,7 +67,6 @@ trait ValidationRule[A]: */ 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 = @@ -203,7 +202,8 @@ object Validate: rule(_.length <= n, msg) /** - * Validates that a string matches a regex pattern. + * Validates that a string matches a regex pattern. Note: Uses `String.matches`, which requires a + * full match (the pattern is implicitly anchored with `^...$`). */ def pattern(regex: String, message: String = ""): ValidationRule[String] = val msg = From 9c47a86bf99d94b6acd0672ce1ea66d0098f4715 Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 17:34:38 -0800 Subject: [PATCH 3/4] refactor: Address Gemini review feedback - validateNow() reads RxVar directly for reliable imperative use - Simplify FormValidation.isValid/errors with foldLeft from initial value - Update plan doc with subscription requirement note Co-Authored-By: Claude Opus 4.6 --- plans/2026-02-05-form-validation.md | 2 +- .../scala/wvlet/uni/dom/ValidateTest.scala | 11 ++-- .../main/scala/wvlet/uni/dom/Validate.scala | 60 ++++++++----------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/plans/2026-02-05-form-validation.md b/plans/2026-02-05-form-validation.md index 5952bd86..fa173b03 100644 --- a/plans/2026-02-05-form-validation.md +++ b/plans/2026-02-05-form-validation.md @@ -37,5 +37,5 @@ Add composable, reactive form validation that integrates naturally with the exis - 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()` +- 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 index 7c1cfaf8..366339bb 100644 --- a/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala @@ -193,14 +193,15 @@ class ValidateTest extends UniTest: cancelable.cancel - test("FieldValidation.validateNow returns Invalid before subscription"): + test("FieldValidation.validateNow reads RxVar directly before subscription"): val input = Rx.variable("hello") val inputV = Validate(input)(Validate.required()) - // Without subscribing, lastValue is None - val result = inputV.validateNow() - result.isValid shouldBe false - result.errors shouldBe Seq("No value available") + // Without subscribing, validateNow falls back to reading from RxVar directly + inputV.validateNow().isValid shouldBe true + + input := "" + inputV.validateNow().isValid shouldBe false // --- FormValidation --- diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala index 550b668d..eb793f56 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala @@ -13,7 +13,7 @@ */ package wvlet.uni.dom -import wvlet.uni.rx.Rx +import wvlet.uni.rx.{Rx, RxVar} /** * Validation result for a field or form. @@ -78,10 +78,17 @@ class FieldValidation[A](source: Rx[A], rules: Seq[ValidationRule[A]]): val errors: Rx[Seq[String]] = state.map(_.errors) /** - * Imperatively check the current value against all rules. + * Imperatively check the current value against all rules. When the source is an RxVar, reads the + * current value directly. Otherwise, uses the last value observed through the reactive chain. */ def validateNow(): ValidationState = - lastValue match + val current = + source match + case rxVar: RxVar[A @unchecked] => + Some(rxVar.get) + case _ => + lastValue + current match case Some(v) => runRules(v) case None => @@ -97,41 +104,26 @@ class FieldValidation[A](source: Rx[A], rules: Seq[ValidationRule[A]]): */ 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 - } + fields + .map(_.isValid) + .foldLeft[Rx[Boolean]](Rx.variable(true)) { (acc, fieldIsValid) => + acc + .join(fieldIsValid) + .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 - } + fields + .map(_.errors) + .foldLeft[Rx[Seq[String]]](Rx.variable(Seq.empty)) { (acc, fieldErrors) => + acc + .join(fieldErrors) + .map { case (a, b) => + a ++ b } + } /** * Imperatively check all fields and return whether the form is valid. From 2aded27dcf831bad0eda479ab3c8e2ad0a0063d5 Mon Sep 17 00:00:00 2001 From: "Taro L. Saito" Date: Thu, 5 Feb 2026 22:39:13 -0800 Subject: [PATCH 4/4] refactor: Simplify validation API per user preferences Keep validateNow() simple with lastValue tracking, restore explicit FormValidation size matching, and trim verbose scaladoc. Co-Authored-By: Claude Opus 4.6 --- .../scala/wvlet/uni/dom/ValidateTest.scala | 10 --- .../main/scala/wvlet/uni/dom/Validate.scala | 68 ++++++++++--------- 2 files changed, 37 insertions(+), 41 deletions(-) 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 index 366339bb..256f267a 100644 --- a/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala +++ b/uni-dom-test/src/test/scala/wvlet/uni/dom/ValidateTest.scala @@ -193,16 +193,6 @@ class ValidateTest extends UniTest: cancelable.cancel - test("FieldValidation.validateNow reads RxVar directly before subscription"): - val input = Rx.variable("hello") - val inputV = Validate(input)(Validate.required()) - - // Without subscribing, validateNow falls back to reading from RxVar directly - inputV.validateNow().isValid shouldBe true - - input := "" - inputV.validateNow().isValid shouldBe false - // --- FormValidation --- test("FormValidation.isValid combines all fields"): diff --git a/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala index 5de1104b..bff8c98a 100644 --- a/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala +++ b/uni/.js/src/main/scala/wvlet/uni/dom/Validate.scala @@ -13,7 +13,7 @@ */ package wvlet.uni.dom -import wvlet.uni.rx.{Rx, RxVar} +import wvlet.uni.rx.{Cancelable, Rx, RxVar} /** * Validation result for a field or form. @@ -67,6 +67,7 @@ trait ValidationRule[A]: */ 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 = @@ -78,17 +79,10 @@ class FieldValidation[A](source: Rx[A], rules: Seq[ValidationRule[A]]): val errors: Rx[Seq[String]] = state.map(_.errors) /** - * Imperatively check the current value against all rules. When the source is an RxVar, reads the - * current value directly. Otherwise, uses the last value observed through the reactive chain. + * Imperatively check the current value against all rules. */ def validateNow(): ValidationState = - val current = - source match - case rxVar: RxVar[A @unchecked] => - Some(rxVar.get) - case _ => - lastValue - current match + lastValue match case Some(v) => runRules(v) case None => @@ -104,26 +98,41 @@ class FieldValidation[A](source: Rx[A], rules: Seq[ValidationRule[A]]): */ class FormValidation(fields: Seq[FieldValidation[?]]): val isValid: Rx[Boolean] = - fields - .map(_.isValid) - .foldLeft[Rx[Boolean]](Rx.variable(true)) { (acc, fieldIsValid) => - acc - .join(fieldIsValid) - .map { case (a, b) => - a && b + 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 - .map(_.errors) - .foldLeft[Rx[Seq[String]]](Rx.variable(Seq.empty)) { (acc, fieldErrors) => - acc - .join(fieldErrors) - .map { case (a, b) => - a ++ b + 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. @@ -194,8 +203,7 @@ object Validate: rule(_.length <= n, msg) /** - * Validates that a string matches a regex pattern. Note: Uses `String.matches`, which requires a - * full match (the pattern is implicitly anchored with `^...$`). + * Validates that a string matches a regex pattern. */ def pattern(regex: String, message: String = ""): ValidationRule[String] = val msg = @@ -206,9 +214,7 @@ object Validate: rule(_.matches(regex), msg) /** - * Validates that a string looks like an email address. Uses a simple practical regex - * (local@domain.tld) rather than a full RFC 5322 pattern. For stricter validation, use - * `pattern()` or `ruleWith()` with a custom regex. + * Validates that a string looks like an email address. */ def email(message: String = "Invalid email address"): ValidationRule[String] = pattern( "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",