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 2e793db..3fc783a 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 @@ -13,7 +13,8 @@ case class ChoiceOption[A](id: String, label: String, value: A) case class Choice[A]( - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean = false ) trait FieldBuilder[A]: @@ -118,12 +119,15 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val options = summon[Choice[A]].options + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, - Some(initialValue.getOrElse(options.head.value)), + initialValue, Validations.requiredA(fieldDescriptor.label)(_), - options + options, + combo ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -135,13 +139,17 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[Option[A]] ): FormComponent[Option[A]] = + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, a => Validation.succeed(a.flatten), - ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + ChoiceOption("", "", None) :: options.map(o => o.copy(value = Some(o.value)) - ) + ), + combo ) class Input[A]( @@ -182,7 +190,8 @@ desc: FieldDescriptor, initialValue: Option[A], validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean )(using fctx: FormBuilderContext) extends FormComponent[A]: private val rawValue: Var[Option[String]] = Var( @@ -195,12 +204,16 @@ .map(validation) override val elements: Seq[HtmlElement] = - renderSelect( + SelectField( desc, - initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + initialValue.flatMap(i => + options.find(_.value == i).map(o => (o.id, o.label)) + ), options.map(o => (o.id, o.label)), - rawValue.writer.contramapSome - ) + validated, + rawValue.writer.contramapSome, + combo + ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( using fctx: FormBuilderContext @@ -218,28 +231,3 @@ ) ) ) - - def renderSelect( - desc: FieldDescriptor, - initialValue: Option[String], - options: List[(String, String)], - observer: Observer[String] - )(using - fctx: FormBuilderContext - ): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.select(Val(false))( - idAttr(desc.idString), - nameAttr(desc.name), - cls( - "block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" - ), - initialValue.map(L.value(_)), - options.map(o => - option(selected(initialValue.contains(o._1)), value(o._1), o._2) - ), - onChange.mapToValue.setAsValue --> observer - ) - ) - ) 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 2e793db..3fc783a 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 @@ -13,7 +13,8 @@ case class ChoiceOption[A](id: String, label: String, value: A) case class Choice[A]( - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean = false ) trait FieldBuilder[A]: @@ -118,12 +119,15 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val options = summon[Choice[A]].options + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, - Some(initialValue.getOrElse(options.head.value)), + initialValue, Validations.requiredA(fieldDescriptor.label)(_), - options + options, + combo ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -135,13 +139,17 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[Option[A]] ): FormComponent[Option[A]] = + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, a => Validation.succeed(a.flatten), - ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + ChoiceOption("", "", None) :: options.map(o => o.copy(value = Some(o.value)) - ) + ), + combo ) class Input[A]( @@ -182,7 +190,8 @@ desc: FieldDescriptor, initialValue: Option[A], validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean )(using fctx: FormBuilderContext) extends FormComponent[A]: private val rawValue: Var[Option[String]] = Var( @@ -195,12 +204,16 @@ .map(validation) override val elements: Seq[HtmlElement] = - renderSelect( + SelectField( desc, - initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + initialValue.flatMap(i => + options.find(_.value == i).map(o => (o.id, o.label)) + ), options.map(o => (o.id, o.label)), - rawValue.writer.contramapSome - ) + validated, + rawValue.writer.contramapSome, + combo + ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( using fctx: FormBuilderContext @@ -218,28 +231,3 @@ ) ) ) - - def renderSelect( - desc: FieldDescriptor, - initialValue: Option[String], - options: List[(String, String)], - observer: Observer[String] - )(using - fctx: FormBuilderContext - ): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.select(Val(false))( - idAttr(desc.idString), - nameAttr(desc.name), - cls( - "block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" - ), - initialValue.map(L.value(_)), - options.map(o => - option(selected(initialValue.contains(o._1)), value(o._1), o._2) - ), - onChange.mapToValue.setAsValue --> observer - ) - ) - ) 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 4728c2a..7eea5f8 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 @@ -85,12 +85,14 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given schema match case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => val desc = SectionDescriptor(name) buildForm(inner)(initialValue).wrap( fctx.formUIFactory .section(desc.title, desc.subtitle.map(textToTextNode))(_*) ) + case Control(name, required, decode, validation, inputType) => val desc = FieldDescriptor(name) FieldBuilder @@ -109,9 +111,11 @@ desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) ) + case z @ Zip(left, right) => val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) val rightComponent = buildForm(right)(initialValue.map(z.toRight)) leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => buildForm(inner)(initialValue.map(from)).map(to) 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 2e793db..3fc783a 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 @@ -13,7 +13,8 @@ case class ChoiceOption[A](id: String, label: String, value: A) case class Choice[A]( - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean = false ) trait FieldBuilder[A]: @@ -118,12 +119,15 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val options = summon[Choice[A]].options + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, - Some(initialValue.getOrElse(options.head.value)), + initialValue, Validations.requiredA(fieldDescriptor.label)(_), - options + options, + combo ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -135,13 +139,17 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[Option[A]] ): FormComponent[Option[A]] = + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, a => Validation.succeed(a.flatten), - ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + ChoiceOption("", "", None) :: options.map(o => o.copy(value = Some(o.value)) - ) + ), + combo ) class Input[A]( @@ -182,7 +190,8 @@ desc: FieldDescriptor, initialValue: Option[A], validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean )(using fctx: FormBuilderContext) extends FormComponent[A]: private val rawValue: Var[Option[String]] = Var( @@ -195,12 +204,16 @@ .map(validation) override val elements: Seq[HtmlElement] = - renderSelect( + SelectField( desc, - initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + initialValue.flatMap(i => + options.find(_.value == i).map(o => (o.id, o.label)) + ), options.map(o => (o.id, o.label)), - rawValue.writer.contramapSome - ) + validated, + rawValue.writer.contramapSome, + combo + ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( using fctx: FormBuilderContext @@ -218,28 +231,3 @@ ) ) ) - - def renderSelect( - desc: FieldDescriptor, - initialValue: Option[String], - options: List[(String, String)], - observer: Observer[String] - )(using - fctx: FormBuilderContext - ): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.select(Val(false))( - idAttr(desc.idString), - nameAttr(desc.name), - cls( - "block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" - ), - initialValue.map(L.value(_)), - options.map(o => - option(selected(initialValue.contains(o._1)), value(o._1), o._2) - ), - onChange.mapToValue.setAsValue --> observer - ) - ) - ) 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 4728c2a..7eea5f8 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 @@ -85,12 +85,14 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given schema match case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => val desc = SectionDescriptor(name) buildForm(inner)(initialValue).wrap( fctx.formUIFactory .section(desc.title, desc.subtitle.map(textToTextNode))(_*) ) + case Control(name, required, decode, validation, inputType) => val desc = FieldDescriptor(name) FieldBuilder @@ -109,9 +111,11 @@ desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) ) + case z @ Zip(left, right) => val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) val rightComponent = buildForm(right)(initialValue.map(z.toRight)) leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => buildForm(inner)(initialValue.map(from)).map(to) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala index cfdb8fc..a25799c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala @@ -9,9 +9,12 @@ FormSchema.BiMap(this, f, g) object FormSchema: + extension [A](f: FormSchema[A]) def *:[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = f.zip(that) + case object Unit extends FormSchema[EmptyTuple] + case class Control[A]( name: String, required: Boolean, @@ -19,6 +22,7 @@ validation: Option[String] => Validated[A], inputType: InputSchema.InputType ) extends FormSchema[A] + object Control: def apply[A](name: String)(using ic: InputSchema[A]): Control[A] = Control( @@ -28,11 +32,14 @@ ic.decodeOptional(name), ic.inputType ) + case class Section[A](name: String, content: FormSchema[A]) extends FormSchema[A] + case class Zip[A, B <: Tuple](left: FormSchema[A], right: FormSchema[B]) extends FormSchema[A *: B]: def toLeft(value: A *: B): A = value.head def toRight(value: A *: B): B = value.tail + case class BiMap[A, B](form: FormSchema[A], f: A => B, g: B => A) extends FormSchema[B] 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 2e793db..3fc783a 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 @@ -13,7 +13,8 @@ case class ChoiceOption[A](id: String, label: String, value: A) case class Choice[A]( - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean = false ) trait FieldBuilder[A]: @@ -118,12 +119,15 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val options = summon[Choice[A]].options + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, - Some(initialValue.getOrElse(options.head.value)), + initialValue, Validations.requiredA(fieldDescriptor.label)(_), - options + options, + combo ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -135,13 +139,17 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[Option[A]] ): FormComponent[Option[A]] = + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, a => Validation.succeed(a.flatten), - ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + ChoiceOption("", "", None) :: options.map(o => o.copy(value = Some(o.value)) - ) + ), + combo ) class Input[A]( @@ -182,7 +190,8 @@ desc: FieldDescriptor, initialValue: Option[A], validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean )(using fctx: FormBuilderContext) extends FormComponent[A]: private val rawValue: Var[Option[String]] = Var( @@ -195,12 +204,16 @@ .map(validation) override val elements: Seq[HtmlElement] = - renderSelect( + SelectField( desc, - initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + initialValue.flatMap(i => + options.find(_.value == i).map(o => (o.id, o.label)) + ), options.map(o => (o.id, o.label)), - rawValue.writer.contramapSome - ) + validated, + rawValue.writer.contramapSome, + combo + ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( using fctx: FormBuilderContext @@ -218,28 +231,3 @@ ) ) ) - - def renderSelect( - desc: FieldDescriptor, - initialValue: Option[String], - options: List[(String, String)], - observer: Observer[String] - )(using - fctx: FormBuilderContext - ): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.select(Val(false))( - idAttr(desc.idString), - nameAttr(desc.name), - cls( - "block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" - ), - initialValue.map(L.value(_)), - options.map(o => - option(selected(initialValue.contains(o._1)), value(o._1), o._2) - ), - onChange.mapToValue.setAsValue --> observer - ) - ) - ) 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 4728c2a..7eea5f8 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 @@ -85,12 +85,14 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given schema match case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => val desc = SectionDescriptor(name) buildForm(inner)(initialValue).wrap( fctx.formUIFactory .section(desc.title, desc.subtitle.map(textToTextNode))(_*) ) + case Control(name, required, decode, validation, inputType) => val desc = FieldDescriptor(name) FieldBuilder @@ -109,9 +111,11 @@ desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) ) + case z @ Zip(left, right) => val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) val rightComponent = buildForm(right)(initialValue.map(z.toRight)) leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => buildForm(inner)(initialValue.map(from)).map(to) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala index cfdb8fc..a25799c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala @@ -9,9 +9,12 @@ FormSchema.BiMap(this, f, g) object FormSchema: + extension [A](f: FormSchema[A]) def *:[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = f.zip(that) + case object Unit extends FormSchema[EmptyTuple] + case class Control[A]( name: String, required: Boolean, @@ -19,6 +22,7 @@ validation: Option[String] => Validated[A], inputType: InputSchema.InputType ) extends FormSchema[A] + object Control: def apply[A](name: String)(using ic: InputSchema[A]): Control[A] = Control( @@ -28,11 +32,14 @@ ic.decodeOptional(name), ic.inputType ) + case class Section[A](name: String, content: FormSchema[A]) extends FormSchema[A] + case class Zip[A, B <: Tuple](left: FormSchema[A], right: FormSchema[B]) extends FormSchema[A *: B]: def toLeft(value: A *: B): A = value.head def toRight(value: A *: B): B = value.tail + case class BiMap[A, B](form: FormSchema[A], f: A => B, g: B => A) extends FormSchema[B] 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 6e9fb9e..8f53885 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 @@ -48,8 +48,26 @@ mods: HtmlMod* ): HtmlElement + def combobox: FormUIFactory.ComboboxComponents + def fileInput(title: String)( buttonMods: HtmlMod* )( inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* ): HtmlElement + +object FormUIFactory: + trait ComboboxComponents: + def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement + + def button(mods: HtmlMod*): HtmlElement + def options(mods: HtmlMod*): HtmlElement + + def option( + label: String, + isActive: Signal[Boolean], + isSelected: 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 index 2e793db..3fc783a 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 @@ -13,7 +13,8 @@ case class ChoiceOption[A](id: String, label: String, value: A) case class Choice[A]( - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean = false ) trait FieldBuilder[A]: @@ -118,12 +119,15 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val options = summon[Choice[A]].options + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, - Some(initialValue.getOrElse(options.head.value)), + initialValue, Validations.requiredA(fieldDescriptor.label)(_), - options + options, + combo ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -135,13 +139,17 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[Option[A]] ): FormComponent[Option[A]] = + val choice = summon[Choice[A]] + val options = choice.options + val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, a => Validation.succeed(a.flatten), - ChoiceOption("", "", None) :: summon[Choice[A]].options.map(o => + ChoiceOption("", "", None) :: options.map(o => o.copy(value = Some(o.value)) - ) + ), + combo ) class Input[A]( @@ -182,7 +190,8 @@ desc: FieldDescriptor, initialValue: Option[A], validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]] + options: List[ChoiceOption[A]], + combo: Boolean )(using fctx: FormBuilderContext) extends FormComponent[A]: private val rawValue: Var[Option[String]] = Var( @@ -195,12 +204,16 @@ .map(validation) override val elements: Seq[HtmlElement] = - renderSelect( + SelectField( desc, - initialValue.flatMap(i => options.find(_.value == i).map(_.id)), + initialValue.flatMap(i => + options.find(_.value == i).map(o => (o.id, o.label)) + ), options.map(o => (o.id, o.label)), - rawValue.writer.contramapSome - ) + validated, + rawValue.writer.contramapSome, + combo + ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( using fctx: FormBuilderContext @@ -218,28 +231,3 @@ ) ) ) - - def renderSelect( - desc: FieldDescriptor, - initialValue: Option[String], - options: List[(String, String)], - observer: Observer[String] - )(using - fctx: FormBuilderContext - ): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.select(Val(false))( - idAttr(desc.idString), - nameAttr(desc.name), - cls( - "block w-full sm:max-w-xs rounded-md border-0 py-1.5 pl-3 pr-10 text-gray-900 ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-indigo-600 sm:text-sm sm:leading-6" - ), - initialValue.map(L.value(_)), - options.map(o => - option(selected(initialValue.contains(o._1)), value(o._1), o._2) - ), - onChange.mapToValue.setAsValue --> observer - ) - ) - ) 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 4728c2a..7eea5f8 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 @@ -85,12 +85,14 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given schema match case FormSchema.Unit => FormComponent.empty + case Section(name, inner) => val desc = SectionDescriptor(name) buildForm(inner)(initialValue).wrap( fctx.formUIFactory .section(desc.title, desc.subtitle.map(textToTextNode))(_*) ) + case Control(name, required, decode, validation, inputType) => val desc = FieldDescriptor(name) FieldBuilder @@ -109,9 +111,11 @@ desc.help.map(t => fctx.formUIFactory.fieldHelp(t.render)) ) ) + case z @ Zip(left, right) => val leftComponent = buildForm(left)(initialValue.map(z.toLeft)) val rightComponent = buildForm(right)(initialValue.map(z.toRight)) leftComponent.zip(rightComponent) + case BiMap(inner, to, from) => buildForm(inner)(initialValue.map(from)).map(to) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala index cfdb8fc..a25799c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormSchema.scala @@ -9,9 +9,12 @@ FormSchema.BiMap(this, f, g) object FormSchema: + extension [A](f: FormSchema[A]) def *:[B <: Tuple](that: FormSchema[B]): FormSchema[A *: B] = f.zip(that) + case object Unit extends FormSchema[EmptyTuple] + case class Control[A]( name: String, required: Boolean, @@ -19,6 +22,7 @@ validation: Option[String] => Validated[A], inputType: InputSchema.InputType ) extends FormSchema[A] + object Control: def apply[A](name: String)(using ic: InputSchema[A]): Control[A] = Control( @@ -28,11 +32,14 @@ ic.decodeOptional(name), ic.inputType ) + case class Section[A](name: String, content: FormSchema[A]) extends FormSchema[A] + case class Zip[A, B <: Tuple](left: FormSchema[A], right: FormSchema[B]) extends FormSchema[A *: B]: def toLeft(value: A *: B): A = value.head def toRight(value: A *: B): B = value.tail + case class BiMap[A, B](form: FormSchema[A], f: A => B, g: B => A) extends FormSchema[B] 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 6e9fb9e..8f53885 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 @@ -48,8 +48,26 @@ mods: HtmlMod* ): HtmlElement + def combobox: FormUIFactory.ComboboxComponents + def fileInput(title: String)( buttonMods: HtmlMod* )( inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* ): HtmlElement + +object FormUIFactory: + trait ComboboxComponents: + def container( + inError: Signal[Boolean], + amendInput: Input => Input = identity + )(mods: HtmlMod*): HtmlElement + + def button(mods: HtmlMod*): HtmlElement + def options(mods: HtmlMod*): HtmlElement + + def option( + label: String, + isActive: Signal[Boolean], + isSelected: Signal[Boolean] + )(mods: HtmlMod*): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SelectField.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SelectField.scala new file mode 100644 index 0000000..0d0ec07 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/SelectField.scala @@ -0,0 +1,93 @@ +package works.iterative.ui.components.laminar.forms + +import com.raquo.laminar.api.L.* +import works.iterative.core.* +import works.iterative.ui.laminar.headless.Combobox + +// TODO: this is a copy of InputField, but with a different logic for selects +// We need to merge the two after the shape is clear +case class SelectField( + desc: FieldDescriptor, + initialValue: Option[(String, String)], + options: List[(String, String)], + validated: Signal[Validated[_]], + observer: Observer[String], + combo: Boolean +)(using fctx: FormBuilderContext): + val hadFocus: Var[Boolean] = Var(false) + + val touched: Var[Boolean] = Var(false) + + 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 + ) + + def makeField: HtmlElement = + if !combo then + val emptyValue = ("", "") + val opts = + if initialValue.isDefined || options.headOption.contains( + emptyValue + ) + then options + else emptyValue :: options + fctx.formUIFactory.select(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + initialValue.map(i => value(i._1)), + opts.map(o => + option(selected(initialValue.contains(o._1)), value(o._1), o._2) + ), + onChange.mapToValue.setAsValue --> observer, + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ) + else + Combobox[(String, String)](initialValue)( + fctx.formUIFactory.combobox + .container( + hasError, + inp => + Combobox + .input(_._2)(inp) + .amend( + onFocus.mapTo(true) --> hadFocus.writer, + onBlur.mapTo(true) --> touched.writer + ) + )( + Combobox.button(fctx.formUIFactory.combobox.button()), + Combobox.options(fctx.formUIFactory.combobox.options()) { v => + val ictx = summon[Combobox.ItemCtx[(String, String)]] + fctx.formUIFactory.combobox + .option(v._2, ictx.isActive, ictx.isSelected)() + }, + Combobox.ctx.query + .map(_.toLowerCase) + .map(v => options.filter(_._2.toLowerCase.contains(v))) + --> Combobox.ctx.itemsWriter, + Combobox.ctx.value.map(_.map(_._1).getOrElse("")) --> observer + ) + ) + + val elements: Seq[HtmlElement] = + Seq( + div( + makeField, + children <-- errors + .map( + _.map[HtmlElement](msg => + fctx.formUIFactory.validationError( + fctx.formMessagesResolver.message(msg) + ) + ) + ) + ) + ) + + val element: Div = div(elements*)