diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index 1bd3bcb..2773e1b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -6,7 +6,15 @@ object Email: def apply(value: String): Validated[Email] = - // TODO: email validation - Validation.succeed(value) + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index 1bd3bcb..2773e1b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -6,7 +6,15 @@ object Email: def apply(value: String): Validated[Email] = - // TODO: email validation - Validation.succeed(value) + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) extension (email: Email) def value: String = email diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala index fa55207..3f13f01 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -1,8 +1,9 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.* +import zio.prelude.Validation import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition sealed trait Form[A] extends FormBuilder[A] @@ -20,13 +21,18 @@ )(_*) ) - case class Zip[A, B](left: Form[A], right: Form[B])(using + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using fctx: FormBuilderContext - ) extends Form[(A, B)]: - override def build(initialValue: Option[(A, B)]): FormComponent[(A, B)] = - val leftComponent = left.build(initialValue.map(_._1)) - val rightComponent = right.build(initialValue.map(_._2)) - leftComponent <*> rightComponent + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using fctx: FormBuilderContext @@ -50,6 +56,25 @@ ) ) + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + extension [A](f: Form[A]) - def zip[B](other: Form[B])(using fctx: FormBuilderContext): Form[(A, B)] = + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = Zip(f, other) diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index 1bd3bcb..2773e1b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -6,7 +6,15 @@ object Email: def apply(value: String): Validated[Email] = - // TODO: email validation - Validation.succeed(value) + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) extension (email: Email) def value: String = email diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala index fa55207..3f13f01 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -1,8 +1,9 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.* +import zio.prelude.Validation import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition sealed trait Form[A] extends FormBuilder[A] @@ -20,13 +21,18 @@ )(_*) ) - case class Zip[A, B](left: Form[A], right: Form[B])(using + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using fctx: FormBuilderContext - ) extends Form[(A, B)]: - override def build(initialValue: Option[(A, B)]): FormComponent[(A, B)] = - val leftComponent = left.build(initialValue.map(_._1)) - val rightComponent = right.build(initialValue.map(_._2)) - leftComponent <*> rightComponent + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using fctx: FormBuilderContext @@ -50,6 +56,25 @@ ) ) + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + extension [A](f: Form[A]) - def zip[B](other: Form[B])(using fctx: FormBuilderContext): Form[(A, B)] = + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index c614d3c..cdb24d8 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -4,6 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal import works.iterative.core.Validated +import app.tulz.tuplez.Composition trait FormComponent[A]: def validated: Signal[Validated[A]] @@ -28,13 +29,10 @@ FormComponent(fc.validated, Seq(wrapper(fc.elements))) def map[B](f: A => B): FormComponent[B] = FormComponent(fc.validated.map(_.map(f)), fc.elements) - - given AssociativeBoth[FormComponent] with - override def both[A, B]( - fa: => FormComponent[A], - fb: => FormComponent[B] - ): FormComponent[(A, B)] = + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = FormComponent( - Signal.combineWithFn(fa.validated, fb.validated)(Validation.validate), - fa.elements ++ fb.elements + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements ) diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala index 1bd3bcb..2773e1b 100644 --- a/core/shared/src/main/scala/works/iterative/core/Email.scala +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -6,7 +6,15 @@ object Email: def apply(value: String): Validated[Email] = - // TODO: email validation - Validation.succeed(value) + // The regex below is taken from the HTML5 spec for "email address state" (https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address) + // and is the most permissive regex we can use that still conforms to the spec. + // Copilot work. + val regex = "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9]" + + "(?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?" + + "(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + + Validation.fromPredicateWith(UserMessage("error.invalid.email"))(value)( + _.matches(regex) + ) extension (email: Email) def value: String = email diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala index fa55207..3f13f01 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -1,8 +1,9 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.* +import zio.prelude.Validation import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.laminar.HtmlRenderable.given +import app.tulz.tuplez.Composition sealed trait Form[A] extends FormBuilder[A] @@ -20,13 +21,18 @@ )(_*) ) - case class Zip[A, B](left: Form[A], right: Form[B])(using + case class Zip[A, B <: Tuple]( + left: Form[A], + right: Form[B] + )(using fctx: FormBuilderContext - ) extends Form[(A, B)]: - override def build(initialValue: Option[(A, B)]): FormComponent[(A, B)] = - val leftComponent = left.build(initialValue.map(_._1)) - val rightComponent = right.build(initialValue.map(_._2)) - leftComponent <*> rightComponent + ) extends Form[A *: B]: + override def build( + initialValue: Option[A *: B] + ): FormComponent[A *: B] = + val leftComponent = left.build(initialValue.map(_.head)) + val rightComponent = right.build(initialValue.map(_.tail)) + leftComponent.zip(rightComponent) case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using fctx: FormBuilderContext @@ -50,6 +56,25 @@ ) ) + case object Empty extends Form[EmptyTuple]: + override def build( + initialValue: Option[EmptyTuple] + ): FormComponent[EmptyTuple] = + FormComponent(Val(Validation.succeed(EmptyTuple)), Nil) + + extension [A <: Tuple](tail: Form[A]) + def prepend[B](head: Form[B])(using + fctx: FormBuilderContext + ): Form[B *: A] = + Zip[B, A](head, tail) + extension [A](f: Form[A]) - def zip[B](other: Form[B])(using fctx: FormBuilderContext): Form[(A, B)] = + def +:[B <: Tuple](other: Form[B])(using + fctx: FormBuilderContext + ): Form[A *: B] = + Zip(f, other) + + def zip[B <: Tuple](other: Form[B])(using + FormBuilderContext + ): Form[A *: B] = Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index c614d3c..cdb24d8 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -4,6 +4,7 @@ import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal import works.iterative.core.Validated +import app.tulz.tuplez.Composition trait FormComponent[A]: def validated: Signal[Validated[A]] @@ -28,13 +29,10 @@ FormComponent(fc.validated, Seq(wrapper(fc.elements))) def map[B](f: A => B): FormComponent[B] = FormComponent(fc.validated.map(_.map(f)), fc.elements) - - given AssociativeBoth[FormComponent] with - override def both[A, B]( - fa: => FormComponent[A], - fb: => FormComponent[B] - ): FormComponent[(A, B)] = + def zip[B <: Tuple](other: FormComponent[B]): FormComponent[A *: B] = FormComponent( - Signal.combineWithFn(fa.validated, fb.validated)(Validation.validate), - fa.elements ++ fb.elements + Signal + .combineWithFn(fc.validated, other.validated)(Validation.validate) + .map(_.map(_ *: _)), + fc.elements ++ other.elements ) diff --git a/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala new file mode 100644 index 0000000..fbcd5c8 --- /dev/null +++ b/ui/js/src/test/scala/works/iterative/ui/components/laminar/forms/FormZipSpec.scala @@ -0,0 +1,31 @@ +package works.iterative.ui.components.laminar.forms + +import zio.test.* +import works.iterative.core.MessageCatalogue +import works.iterative.ui.components.ComponentContext + +object FormZipSpec extends ZIOSpecDefault: + def spec = suite("Form using zip operator")( + test("should form a tuple") { + val fd = new FieldDescriptor: + override def idString: String = ??? + override def name: String = ??? + override def placeholder: Option[String] = ??? + override def id: FieldId = ??? + override def label = ??? + override def help = ??? + + given FieldBuilder[String] = FieldBuilder.requiredInput[String] + + given FormBuilderContext = new FormBuilderContext: + override def formUIFactory: FormUIFactory = ??? + override def formMessagesResolver: FormMessagesResolver = ??? + + val form = Form.Input[String](fd).zip(Form.Empty) + val form2 = Form.Input[String](fd) +: Form.Empty + assertTrue( + form.isInstanceOf[Form[String *: EmptyTuple]], + form2.isInstanceOf[Form[String *: EmptyTuple]] + ) + } + )