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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..827c296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,23 @@ +package works.iterative.ui.components.laminar.forms + +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 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 = + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + inputComponent.element, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..827c296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,23 @@ +package works.iterative.ui.components.laminar.forms + +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 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 = + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + inputComponent.element, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..827c296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,23 @@ +package works.iterative.ui.components.laminar.forms + +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 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 = + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + inputComponent.element, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..827c296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,23 @@ +package works.iterative.ui.components.laminar.forms + +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 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 = + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + inputComponent.element, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver 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 37d80f8..4653e5f 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 @@ -1,65 +1,33 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.* +import zio.prelude.Validation +import com.raquo.laminar.api.L 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 InputField[A]: - def render: ReactiveHtmlElement[html.Input] - -case class InvalidValue(name: String, message: String => UserMessage) - -type Validated[A] = Validation[InvalidValue, A] - -sealed trait Form[A]: - def value: Validated[A] - -object Form: - case class Input(name: String) extends Form[String]: - override def value: Validated[String] = - Validation.fail( - InvalidValue(name, UserMessage("error.value.required", _)) - ) - -trait FormBuilderModule: - def formMessagesResolver: FormMessagesResolver - def formUIFactory: FormUIFactory +trait FormBuilderModule(using fctx: FormBuilderContext): def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def renderForm[A](form: Form[A]): HtmlElement = form match - case i @ Form.Input(name) => - val userLabel = formMessagesResolver.label(name) - formUIFactory.field( - formUIFactory.label(userLabel)() - )( - formUIFactory.input( - name, - placeholder = formMessagesResolver.placeholder(name) - )(), - i.value.fold( - msgs => - msgs - .map(msg => - formUIFactory.validationError( - formMessagesResolver.message(msg.message(userLabel)) - ) - ) - .toList, - _ => List.empty[HtmlMod] + 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))) ) - ) - - def build: HtmlElement = - formUIFactory.form( - onSubmit.preventDefault.map(_ => form.value).collect { - case Validation.Success(_, value) => value - } --> submit - )(renderForm(form))( - formUIFactory.submit( - formMessagesResolver.label("submit") - ) - ) 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..827c296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,23 @@ +package works.iterative.ui.components.laminar.forms + +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 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 = + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + inputComponent.element, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver 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 37d80f8..4653e5f 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 @@ -1,65 +1,33 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.* +import zio.prelude.Validation +import com.raquo.laminar.api.L 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 InputField[A]: - def render: ReactiveHtmlElement[html.Input] - -case class InvalidValue(name: String, message: String => UserMessage) - -type Validated[A] = Validation[InvalidValue, A] - -sealed trait Form[A]: - def value: Validated[A] - -object Form: - case class Input(name: String) extends Form[String]: - override def value: Validated[String] = - Validation.fail( - InvalidValue(name, UserMessage("error.value.required", _)) - ) - -trait FormBuilderModule: - def formMessagesResolver: FormMessagesResolver - def formUIFactory: FormUIFactory +trait FormBuilderModule(using fctx: FormBuilderContext): def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def renderForm[A](form: Form[A]): HtmlElement = form match - case i @ Form.Input(name) => - val userLabel = formMessagesResolver.label(name) - formUIFactory.field( - formUIFactory.label(userLabel)() - )( - formUIFactory.input( - name, - placeholder = formMessagesResolver.placeholder(name) - )(), - i.value.fold( - msgs => - msgs - .map(msg => - formUIFactory.validationError( - formMessagesResolver.message(msg.message(userLabel)) - ) - ) - .toList, - _ => List.empty[HtmlMod] + 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))) ) - ) - - def build: HtmlElement = - formUIFactory.form( - onSubmit.preventDefault.map(_ => form.value).collect { - case Validation.Success(_, value) => value - } --> submit - )(renderForm(form))( - formUIFactory.submit( - formMessagesResolver.label("submit") - ) - ) 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 new file mode 100644 index 0000000..c058f88 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..827c296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,23 @@ +package works.iterative.ui.components.laminar.forms + +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 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 = + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + inputComponent.element, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver 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 37d80f8..4653e5f 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 @@ -1,65 +1,33 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.* +import zio.prelude.Validation +import com.raquo.laminar.api.L 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 InputField[A]: - def render: ReactiveHtmlElement[html.Input] - -case class InvalidValue(name: String, message: String => UserMessage) - -type Validated[A] = Validation[InvalidValue, A] - -sealed trait Form[A]: - def value: Validated[A] - -object Form: - case class Input(name: String) extends Form[String]: - override def value: Validated[String] = - Validation.fail( - InvalidValue(name, UserMessage("error.value.required", _)) - ) - -trait FormBuilderModule: - def formMessagesResolver: FormMessagesResolver - def formUIFactory: FormUIFactory +trait FormBuilderModule(using fctx: FormBuilderContext): def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def renderForm[A](form: Form[A]): HtmlElement = form match - case i @ Form.Input(name) => - val userLabel = formMessagesResolver.label(name) - formUIFactory.field( - formUIFactory.label(userLabel)() - )( - formUIFactory.input( - name, - placeholder = formMessagesResolver.placeholder(name) - )(), - i.value.fold( - msgs => - msgs - .map(msg => - formUIFactory.validationError( - formMessagesResolver.message(msg.message(userLabel)) - ) - ) - .toList, - _ => List.empty[HtmlMod] + 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))) ) - ) - - def build: HtmlElement = - formUIFactory.form( - onSubmit.preventDefault.map(_ => form.value).collect { - case Validation.Success(_, value) => value - } --> submit - )(renderForm(form))( - formUIFactory.submit( - formMessagesResolver.label("submit") - ) - ) 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 new file mode 100644 index 0000000..c058f88 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala index fd3d3e3..467a97c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -13,20 +13,24 @@ content: HtmlMod* ): HtmlElement - def label(labelText: String, forId: Option[String] = None)( + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( mods: HtmlMod* ): ReactiveHtmlElement[html.Label] def field(label: HtmlMod)(content: HtmlMod*): HtmlElement - def submit(label: HtmlMod): HtmlElement + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement def validationError(text: HtmlMod): HtmlElement - def input( - name: String, - id: Option[String] = None, - placeholder: Option[String] = None - )( - mods: HtmlMod* - ): ReactiveHtmlElement[html.Input] + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement 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 new file mode 100644 index 0000000..8847d67 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldBuilder.scala @@ -0,0 +1,78 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 FieldBuilder[A]: + def required: Boolean + def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] + +object FieldBuilder: + + given (using fctx: FormBuilderContext): FieldBuilder[String] with + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[String] + ): FormComponent[String] = + StringInputField(fieldDescriptor, initialValue) + + class StringInputField( + desc: FieldDescriptor, + initialValue: Option[String] = None + )(using fctx: FormBuilderContext) + extends FormComponent[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[String]] = rawValue.signal.map { + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", desc.label)) + } + + override val element: 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 + ) + + 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) + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala new file mode 100644 index 0000000..03b1a57 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FieldDescriptor.scala @@ -0,0 +1,24 @@ +package works.iterative.ui.components.laminar.forms + +import works.iterative.core.PlainMultiLine +import works.iterative.ui.components.tailwind.ComponentContext + +trait FieldDescriptor: + def id: FieldId + def idString: String + def name: String + def label: String + def help: Option[PlainMultiLine] + def placeholder: Option[PlainMultiLine] + +object FieldDescriptor: + def apply(fieldId: FieldId)(using ctx: ComponentContext[_]): FieldDescriptor = + new FieldDescriptor: + override def id: FieldId = fieldId + override def idString: String = fieldId + override def name: String = fieldId + override def label: String = ctx.messages(fieldId) + override def help: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".help") + override def placeholder: Option[PlainMultiLine] = + ctx.messages.get(fieldId + ".placeholder") 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 new file mode 100644 index 0000000..827c296 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -0,0 +1,23 @@ +package works.iterative.ui.components.laminar.forms + +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 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 = + fctx.formUIFactory.field( + fctx.formUIFactory.label(desc.label, required = field.required)() + )( + inputComponent.element, + desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala new file mode 100644 index 0000000..5947c24 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilder.scala @@ -0,0 +1,4 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilder[A]: + def build(initialValue: Option[A]): FormComponent[A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala new file mode 100644 index 0000000..fd4e89f --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormBuilderContext.scala @@ -0,0 +1,5 @@ +package works.iterative.ui.components.laminar.forms + +trait FormBuilderContext: + def formUIFactory: FormUIFactory + def formMessagesResolver: FormMessagesResolver 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 37d80f8..4653e5f 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 @@ -1,65 +1,33 @@ package works.iterative.ui.components.laminar.forms -import zio.prelude.* +import zio.prelude.Validation +import com.raquo.laminar.api.L 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 InputField[A]: - def render: ReactiveHtmlElement[html.Input] - -case class InvalidValue(name: String, message: String => UserMessage) - -type Validated[A] = Validation[InvalidValue, A] - -sealed trait Form[A]: - def value: Validated[A] - -object Form: - case class Input(name: String) extends Form[String]: - override def value: Validated[String] = - Validation.fail( - InvalidValue(name, UserMessage("error.value.required", _)) - ) - -trait FormBuilderModule: - def formMessagesResolver: FormMessagesResolver - def formUIFactory: FormUIFactory +trait FormBuilderModule(using fctx: FormBuilderContext): def buildForm[A](form: Form[A], submit: Observer[A]): HtmlFormBuilder[A] = HtmlFormBuilder[A](form, submit) case class HtmlFormBuilder[A](form: Form[A], submit: Observer[A]): - def renderForm[A](form: Form[A]): HtmlElement = form match - case i @ Form.Input(name) => - val userLabel = formMessagesResolver.label(name) - formUIFactory.field( - formUIFactory.label(userLabel)() - )( - formUIFactory.input( - name, - placeholder = formMessagesResolver.placeholder(name) - )(), - i.value.fold( - msgs => - msgs - .map(msg => - formUIFactory.validationError( - formMessagesResolver.message(msg.message(userLabel)) - ) - ) - .toList, - _ => List.empty[HtmlMod] + 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))) ) - ) - - def build: HtmlElement = - formUIFactory.form( - onSubmit.preventDefault.map(_ => form.value).collect { - case Validation.Success(_, value) => value - } --> submit - )(renderForm(form))( - formUIFactory.submit( - formMessagesResolver.label("submit") - ) - ) 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 new file mode 100644 index 0000000..c058f88 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.Validation +import com.raquo.laminar.api.L +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 diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala index fd3d3e3..467a97c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormUIFactory.scala @@ -13,20 +13,24 @@ content: HtmlMod* ): HtmlElement - def label(labelText: String, forId: Option[String] = None)( + def label( + labelText: String, + forId: Option[String] = None, + required: Boolean = false + )( mods: HtmlMod* ): ReactiveHtmlElement[html.Label] def field(label: HtmlMod)(content: HtmlMod*): HtmlElement - def submit(label: HtmlMod): HtmlElement + def submit(label: HtmlMod)(mods: HtmlMod*): HtmlElement def validationError(text: HtmlMod): HtmlElement - def input( - name: String, - id: Option[String] = None, - placeholder: Option[String] = None - )( - mods: HtmlMod* - ): ReactiveHtmlElement[html.Input] + def fieldHelp(text: HtmlMod): HtmlElement + + def helpTextMods: HtmlMod + + def errorTextMods: HtmlMod + + def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala new file mode 100644 index 0000000..a4a0c71 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/package.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.laminar + +import zio.prelude.Validation +import works.iterative.core.UserMessage + +package object forms: + type FieldId = String + type FieldLabel = String + + type Validated[A] = Validation[UserMessage, A]