diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 8847d67..ef24ec3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -44,7 +44,7 @@ Validation.fail(UserMessage("error.value.required", desc.label)) } - override val element: HtmlElement = + override val elements: Seq[HtmlElement] = val hasError: Signal[Boolean] = validated.combineWithFn(touched.signal)((v, t) => if t then v.fold(_ => true, _ => false) else false @@ -55,24 +55,85 @@ if t then v.fold(_.toList, _ => List.empty) else Nil ) - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) ) ) - ) + ) + ) + + given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[String]] + ): FormComponent[Option[String]] = + OptionStringInputField(fieldDescriptor, initialValue) + + class OptionStringInputField( + desc: FieldDescriptor, + initialValue: Option[Option[String]] = None + )(using fctx: FormBuilderContext) + extends FormComponent[Option[String]]: + private val rawValue: Var[Option[String]] = Var(None) + private val hadFocus: Var[Boolean] = Var(false) + private val touched: Var[Boolean] = Var(false) + + override val validated: Signal[Validated[Option[String]]] = + rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => + Validation.succeed(Some(value)) + case _ => Validation.succeed(None) + } + + override val elements: Seq[HtmlElement] = + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.flatten.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 8847d67..ef24ec3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -44,7 +44,7 @@ Validation.fail(UserMessage("error.value.required", desc.label)) } - override val element: HtmlElement = + override val elements: Seq[HtmlElement] = val hasError: Signal[Boolean] = validated.combineWithFn(touched.signal)((v, t) => if t then v.fold(_ => true, _ => false) else false @@ -55,24 +55,85 @@ if t then v.fold(_.toList, _ => List.empty) else Nil ) - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) ) ) - ) + ) + ) + + given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[String]] + ): FormComponent[Option[String]] = + OptionStringInputField(fieldDescriptor, initialValue) + + class OptionStringInputField( + desc: FieldDescriptor, + initialValue: Option[Option[String]] = None + )(using fctx: FormBuilderContext) + extends FormComponent[Option[String]]: + private val rawValue: Var[Option[String]] = Var(None) + private val hadFocus: Var[Boolean] = Var(false) + private val touched: Var[Boolean] = Var(false) + + override val validated: Signal[Validated[Option[String]]] = + rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => + Validation.succeed(Some(value)) + case _ => Validation.succeed(None) + } + + override val elements: Seq[HtmlElement] = + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.flatten.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) ) 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 827c296..2a72409 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,23 +1,55 @@ package works.iterative.ui.components.laminar.forms +import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.HtmlRenderable.given sealed trait Form[A] extends FormBuilder[A] object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B](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 + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using fctx: FormBuilderContext ) extends Form[A]: override def build(initialValue: Option[A]): FormComponent[A] = val field = summon[FieldBuilder[A]] - val inputComponent = field.build(desc, initialValue) - new FormComponent[A]: - override val validated: Signal[Validated[A]] = inputComponent.validated - override val element: HtmlElement = + field + .build(desc, initialValue) + .wrap( fctx.formUIFactory.field( fctx.formUIFactory.label(desc.label, required = field.required)() )( - inputComponent.element, + _, desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) + ) + + extension [A](f: Form[A]) + def zip[B](other: Form[B])(using fctx: FormBuilderContext): Form[(A, B)] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 8847d67..ef24ec3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -44,7 +44,7 @@ Validation.fail(UserMessage("error.value.required", desc.label)) } - override val element: HtmlElement = + override val elements: Seq[HtmlElement] = val hasError: Signal[Boolean] = validated.combineWithFn(touched.signal)((v, t) => if t then v.fold(_ => true, _ => false) else false @@ -55,24 +55,85 @@ if t then v.fold(_.toList, _ => List.empty) else Nil ) - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) ) ) - ) + ) + ) + + given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[String]] + ): FormComponent[Option[String]] = + OptionStringInputField(fieldDescriptor, initialValue) + + class OptionStringInputField( + desc: FieldDescriptor, + initialValue: Option[Option[String]] = None + )(using fctx: FormBuilderContext) + extends FormComponent[Option[String]]: + private val rawValue: Var[Option[String]] = Var(None) + private val hadFocus: Var[Boolean] = Var(false) + private val touched: Var[Boolean] = Var(false) + + override val validated: Signal[Validated[Option[String]]] = + rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => + Validation.succeed(Some(value)) + case _ => Validation.succeed(None) + } + + override val elements: Seq[HtmlElement] = + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.flatten.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) ) 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 827c296..2a72409 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,23 +1,55 @@ package works.iterative.ui.components.laminar.forms +import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.HtmlRenderable.given sealed trait Form[A] extends FormBuilder[A] object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B](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 + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using fctx: FormBuilderContext ) extends Form[A]: override def build(initialValue: Option[A]): FormComponent[A] = val field = summon[FieldBuilder[A]] - val inputComponent = field.build(desc, initialValue) - new FormComponent[A]: - override val validated: Signal[Validated[A]] = inputComponent.validated - override val element: HtmlElement = + field + .build(desc, initialValue) + .wrap( fctx.formUIFactory.field( fctx.formUIFactory.label(desc.label, required = field.required)() )( - inputComponent.element, + _, desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) + ) + + extension [A](f: Form[A]) + def zip[B](other: Form[B])(using fctx: FormBuilderContext): Form[(A, B)] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 4653e5f..c8a1b53 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -19,15 +19,16 @@ case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A]): FormComponent[A] = val f = form.build(initialValue) - new FormComponent[A]: - override val validated: Signal[Validated[A]] = f.validated - override val element: HtmlElement = - fctx.formUIFactory.form( - onSubmit.preventDefault.compose(_.sample(f.validated).collect { - case Validation.Success(_, value) => value - }) --> submit - )(f.element)( - fctx.formUIFactory.submit( - fctx.formMessagesResolver.label("submit") - )(disabled <-- f.validated.map(_.fold(_ => true, _ => false))) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 8847d67..ef24ec3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -44,7 +44,7 @@ Validation.fail(UserMessage("error.value.required", desc.label)) } - override val element: HtmlElement = + override val elements: Seq[HtmlElement] = val hasError: Signal[Boolean] = validated.combineWithFn(touched.signal)((v, t) => if t then v.fold(_ => true, _ => false) else false @@ -55,24 +55,85 @@ if t then v.fold(_.toList, _ => List.empty) else Nil ) - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) ) ) - ) + ) + ) + + given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[String]] + ): FormComponent[Option[String]] = + OptionStringInputField(fieldDescriptor, initialValue) + + class OptionStringInputField( + desc: FieldDescriptor, + initialValue: Option[Option[String]] = None + )(using fctx: FormBuilderContext) + extends FormComponent[Option[String]]: + private val rawValue: Var[Option[String]] = Var(None) + private val hadFocus: Var[Boolean] = Var(false) + private val touched: Var[Boolean] = Var(false) + + override val validated: Signal[Validated[Option[String]]] = + rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => + Validation.succeed(Some(value)) + case _ => Validation.succeed(None) + } + + override val elements: Seq[HtmlElement] = + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.flatten.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) ) 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 827c296..2a72409 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,23 +1,55 @@ package works.iterative.ui.components.laminar.forms +import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.HtmlRenderable.given sealed trait Form[A] extends FormBuilder[A] object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B](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 + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using fctx: FormBuilderContext ) extends Form[A]: override def build(initialValue: Option[A]): FormComponent[A] = val field = summon[FieldBuilder[A]] - val inputComponent = field.build(desc, initialValue) - new FormComponent[A]: - override val validated: Signal[Validated[A]] = inputComponent.validated - override val element: HtmlElement = + field + .build(desc, initialValue) + .wrap( fctx.formUIFactory.field( fctx.formUIFactory.label(desc.label, required = field.required)() )( - inputComponent.element, + _, desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) + ) + + extension [A](f: Form[A]) + def zip[B](other: Form[B])(using fctx: FormBuilderContext): Form[(A, B)] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 4653e5f..c8a1b53 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -19,15 +19,16 @@ case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A]): FormComponent[A] = val f = form.build(initialValue) - new FormComponent[A]: - override val validated: Signal[Validated[A]] = f.validated - override val element: HtmlElement = - fctx.formUIFactory.form( - onSubmit.preventDefault.compose(_.sample(f.validated).collect { - case Validation.Success(_, value) => value - }) --> submit - )(f.element)( - fctx.formUIFactory.submit( - fctx.formMessagesResolver.label("submit") - )(disabled <-- f.validated.map(_.fold(_ => true, _ => false))) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) ) + ) + ) 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 c058f88..d597162 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 @@ -1,17 +1,39 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.Validation -import com.raquo.laminar.api.L +import zio.prelude.* import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom.html -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.core.UserMessage -import works.iterative.core.PlainMultiLine import com.raquo.airstream.core.Signal -import works.iterative.ui.components.tailwind.HtmlRenderable.given -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.core.MessageCatalogue trait FormComponent[A]: def validated: Signal[Validated[A]] - def element: HtmlElement + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + 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)] = + FormComponent( + Signal.combineWithFn(fa.validated, fb.validated)(Validation.validate), + fa.elements ++ fb.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala index 8847d67..ef24ec3 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -44,7 +44,7 @@ Validation.fail(UserMessage("error.value.required", desc.label)) } - override val element: HtmlElement = + override val elements: Seq[HtmlElement] = val hasError: Signal[Boolean] = validated.combineWithFn(touched.signal)((v, t) => if t then v.fold(_ => true, _ => false) else false @@ -55,24 +55,85 @@ if t then v.fold(_.toList, _ => List.empty) else Nil ) - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) ) ) - ) + ) + ) + + given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[String]] + ): FormComponent[Option[String]] = + OptionStringInputField(fieldDescriptor, initialValue) + + class OptionStringInputField( + desc: FieldDescriptor, + initialValue: Option[Option[String]] = None + )(using fctx: FormBuilderContext) + extends FormComponent[Option[String]]: + private val rawValue: Var[Option[String]] = Var(None) + private val hadFocus: Var[Boolean] = Var(false) + private val touched: Var[Boolean] = Var(false) + + override val validated: Signal[Validated[Option[String]]] = + rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => + Validation.succeed(Some(value)) + case _ => Validation.succeed(None) + } + + override val elements: Seq[HtmlElement] = + val hasError: Signal[Boolean] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_ => true, _ => false) else false + ) + + val errors: Signal[List[UserMessage]] = + validated.combineWithFn(touched.signal)((v, t) => + if t then v.fold(_.toList, _ => List.empty) else Nil + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.flatten.map(L.value(_)), + onInput.mapToValue.setAsValue --> rawValue.writer.contramap { + (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) + }, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ), + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) ) 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 827c296..2a72409 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,23 +1,55 @@ package works.iterative.ui.components.laminar.forms +import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import works.iterative.ui.components.tailwind.HtmlRenderable.given sealed trait Form[A] extends FormBuilder[A] object Form: + case class Section[A](desc: SectionDescriptor)(content: Form[A])(using + fctx: FormBuilderContext + ) extends Form[A]: + override def build(initialValue: Option[A]): FormComponent[A] = + content + .build(initialValue) + .wrap( + fctx.formUIFactory.section( + desc.title, + desc.subtitle.map(textToTextNode(_)) + )(_*) + ) + + case class Zip[A, B](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 + + case class BiMap[A, B](form: Form[A], f: A => B, g: B => A)(using + fctx: FormBuilderContext + ) extends Form[B]: + override def build(initialValue: Option[B]): FormComponent[B] = + form.build(initialValue.map(g)).map(f) + case class Input[A: FieldBuilder](desc: FieldDescriptor)(using fctx: FormBuilderContext ) extends Form[A]: override def build(initialValue: Option[A]): FormComponent[A] = val field = summon[FieldBuilder[A]] - val inputComponent = field.build(desc, initialValue) - new FormComponent[A]: - override val validated: Signal[Validated[A]] = inputComponent.validated - override val element: HtmlElement = + field + .build(desc, initialValue) + .wrap( fctx.formUIFactory.field( fctx.formUIFactory.label(desc.label, required = field.required)() )( - inputComponent.element, + _, desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) + ) + + extension [A](f: Form[A]) + def zip[B](other: Form[B])(using fctx: FormBuilderContext): Form[(A, B)] = + Zip(f, other) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala index 4653e5f..c8a1b53 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderModule.scala @@ -19,15 +19,16 @@ case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): def build(initialValue: Option[A]): FormComponent[A] = val f = form.build(initialValue) - new FormComponent[A]: - override val validated: Signal[Validated[A]] = f.validated - override val element: HtmlElement = - fctx.formUIFactory.form( - onSubmit.preventDefault.compose(_.sample(f.validated).collect { - case Validation.Success(_, value) => value - }) --> submit - )(f.element)( - fctx.formUIFactory.submit( - fctx.formMessagesResolver.label("submit") - )(disabled <-- f.validated.map(_.fold(_ => true, _ => false))) + f.wrap( + fctx.formUIFactory.form( + onSubmit.preventDefault.compose(_.sample(f.validated).collect { + case Validation.Success(_, value) => value + }) --> submit + )(_)( + fctx.formUIFactory.submit( + fctx.formMessagesResolver.label("submit") + )( + disabled <-- f.validated.map(_.fold(_ => true, _ => false)) ) + ) + ) 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 c058f88..d597162 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 @@ -1,17 +1,39 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.Validation -import com.raquo.laminar.api.L +import zio.prelude.* import com.raquo.laminar.api.L.{*, given} -import org.scalajs.dom.html -import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.core.UserMessage -import works.iterative.core.PlainMultiLine import com.raquo.airstream.core.Signal -import works.iterative.ui.components.tailwind.HtmlRenderable.given -import works.iterative.ui.components.tailwind.ComponentContext -import works.iterative.core.MessageCatalogue trait FormComponent[A]: def validated: Signal[Validated[A]] - def element: HtmlElement + def elements: Seq[HtmlElement] + +object FormComponent: + def apply[A]( + v: Signal[Validated[A]], + e: HtmlElement + ): FormComponent[A] = apply(v, Seq(e)) + + def apply[A]( + v: Signal[Validated[A]], + e: Seq[HtmlElement] + ): FormComponent[A] = + new FormComponent: + override def validated = v + override def elements = e + + extension [A](fc: FormComponent[A]) + def wrap(wrapper: Seq[HtmlElement] => HtmlElement): FormComponent[A] = + 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)] = + FormComponent( + Signal.combineWithFn(fa.validated, fb.validated)(Validation.validate), + fa.elements ++ fb.elements + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala new file mode 100644 index 0000000..276d464 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SectionDescriptor.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.ui.components.tailwind.ComponentContext + +trait SectionDescriptor: + def title: String + def subtitle: Option[String] + +object SectionDescriptor: + def apply(id: String)(using ctx: ComponentContext[_]): SectionDescriptor = + new SectionDescriptor: + override def title: String = ctx.messages(id) + override def subtitle: Option[String] = + ctx.messages.get(id + ".subtitle")