diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala index e120d49..ae055bc 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala @@ -27,13 +27,20 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringDecoder[A]( + factory: ValidatedStringFactory[A] + ): JsonDecoder[A] = + JsonDecoder.string.mapOrFail(factory.apply andThen fromValidation) + + def validatedStringEncoder[A]( + factory: ValidatedStringFactory[A] + ): JsonEncoder[A] = + JsonEncoder.string.contramap(factory.getter) + def validatedStringCodec[A]( factory: ValidatedStringFactory[A] ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) + JsonCodec(validatedStringEncoder(factory), validatedStringDecoder(factory)) given fromValidatedStringCodec[A](using factory: ValidatedStringFactory[A] diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala index e120d49..ae055bc 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala @@ -27,13 +27,20 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringDecoder[A]( + factory: ValidatedStringFactory[A] + ): JsonDecoder[A] = + JsonDecoder.string.mapOrFail(factory.apply andThen fromValidation) + + def validatedStringEncoder[A]( + factory: ValidatedStringFactory[A] + ): JsonEncoder[A] = + JsonEncoder.string.contramap(factory.getter) + def validatedStringCodec[A]( factory: ValidatedStringFactory[A] ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) + JsonCodec(validatedStringEncoder(factory), validatedStringDecoder(factory)) given fromValidatedStringCodec[A](using factory: ValidatedStringFactory[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 index 3fc783a..8030789 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 @@ -10,12 +10,21 @@ import scala.util.NotGiven -case class ChoiceOption[A](id: String, label: String, value: A) - case class Choice[A]( - options: List[ChoiceOption[A]], - combo: Boolean = false -) + options: List[A], + id: A => String, + label: A => String, + combo: Boolean = false, + add: Option[String => Validated[A]] = None +): + def optional: Choice[Option[A]] = + Choice[Option[A]]( + options = None :: options.map(Some(_)), + id = _.map(id).getOrElse(""), + label = _.map(label).getOrElse(""), + combo = combo, + add = add.map(_.andThen(_.map(Some(_)))) + ) trait FieldBuilder[A]: def required: Boolean @@ -119,15 +128,10 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val choice = summon[Choice[A]] - val options = choice.options - val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, - Validations.requiredA(fieldDescriptor.label)(_), - options, - combo + Validations.requiredA(fieldDescriptor.label)(_) ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -140,17 +144,11 @@ 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) :: options.map(o => - o.copy(value = Some(o.value)) - ), - combo - ) + a => Validation.succeed(a.flatten) + )(using choice.optional) class Input[A]( desc: FieldDescriptor, @@ -189,30 +187,51 @@ class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], - validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]], - combo: Boolean + validation: Option[A] => Validated[A] + )(using + choice: Choice[A] )(using fctx: FormBuilderContext) extends FormComponent[A]: + private val Choice(options, id, label, combo, add) = choice + + private def findValue(i: String): Option[A] = options.find(id(_) == i) + private val rawValue: Var[Option[String]] = Var( - initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + initialValue.map(id(_)) ) + private def selectValue(id: Option[String]): Validated[Option[A]] = + def constructValue(id: String): Validated[Option[A]] = + add.fold(Validation.succeed(None))(_(id).map(Some(_))) + + def findOrConstructValue(id: String): Validated[Option[A]] = + findValue(id) + .map(v => Validation.succeed(Some(v))) + .getOrElse(constructValue(id)) + + id.fold(Validation.succeed(None))(findOrConstructValue) + override val validated: Signal[Validated[A]] = rawValue.signal - .map(_.flatMap(i => options.find(_.id == i).map(_.value))) - .map(validation) + .map(selectValue) + .map(_.flatMap(validation)) override val elements: Seq[HtmlElement] = + val addValue: Option[String => (String, String)] = add.map(_ => { + val msg = fctx.formMessagesResolver.message( + UserMessage(s"add.${desc.idString}") + ) + v => (v, s"$msg: $v") + }) + SelectField( desc, - initialValue.flatMap(i => - options.find(_.value == i).map(o => (o.id, o.label)) - ), - options.map(o => (o.id, o.label)), + initialValue.map(i => (id(i), label(i))), + options.map(o => (id(o), label(o))), validated, rawValue.writer.contramapSome, - combo + combo, + addValue ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala index e120d49..ae055bc 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala @@ -27,13 +27,20 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringDecoder[A]( + factory: ValidatedStringFactory[A] + ): JsonDecoder[A] = + JsonDecoder.string.mapOrFail(factory.apply andThen fromValidation) + + def validatedStringEncoder[A]( + factory: ValidatedStringFactory[A] + ): JsonEncoder[A] = + JsonEncoder.string.contramap(factory.getter) + def validatedStringCodec[A]( factory: ValidatedStringFactory[A] ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) + JsonCodec(validatedStringEncoder(factory), validatedStringDecoder(factory)) given fromValidatedStringCodec[A](using factory: ValidatedStringFactory[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 index 3fc783a..8030789 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 @@ -10,12 +10,21 @@ import scala.util.NotGiven -case class ChoiceOption[A](id: String, label: String, value: A) - case class Choice[A]( - options: List[ChoiceOption[A]], - combo: Boolean = false -) + options: List[A], + id: A => String, + label: A => String, + combo: Boolean = false, + add: Option[String => Validated[A]] = None +): + def optional: Choice[Option[A]] = + Choice[Option[A]]( + options = None :: options.map(Some(_)), + id = _.map(id).getOrElse(""), + label = _.map(label).getOrElse(""), + combo = combo, + add = add.map(_.andThen(_.map(Some(_)))) + ) trait FieldBuilder[A]: def required: Boolean @@ -119,15 +128,10 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val choice = summon[Choice[A]] - val options = choice.options - val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, - Validations.requiredA(fieldDescriptor.label)(_), - options, - combo + Validations.requiredA(fieldDescriptor.label)(_) ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -140,17 +144,11 @@ 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) :: options.map(o => - o.copy(value = Some(o.value)) - ), - combo - ) + a => Validation.succeed(a.flatten) + )(using choice.optional) class Input[A]( desc: FieldDescriptor, @@ -189,30 +187,51 @@ class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], - validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]], - combo: Boolean + validation: Option[A] => Validated[A] + )(using + choice: Choice[A] )(using fctx: FormBuilderContext) extends FormComponent[A]: + private val Choice(options, id, label, combo, add) = choice + + private def findValue(i: String): Option[A] = options.find(id(_) == i) + private val rawValue: Var[Option[String]] = Var( - initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + initialValue.map(id(_)) ) + private def selectValue(id: Option[String]): Validated[Option[A]] = + def constructValue(id: String): Validated[Option[A]] = + add.fold(Validation.succeed(None))(_(id).map(Some(_))) + + def findOrConstructValue(id: String): Validated[Option[A]] = + findValue(id) + .map(v => Validation.succeed(Some(v))) + .getOrElse(constructValue(id)) + + id.fold(Validation.succeed(None))(findOrConstructValue) + override val validated: Signal[Validated[A]] = rawValue.signal - .map(_.flatMap(i => options.find(_.id == i).map(_.value))) - .map(validation) + .map(selectValue) + .map(_.flatMap(validation)) override val elements: Seq[HtmlElement] = + val addValue: Option[String => (String, String)] = add.map(_ => { + val msg = fctx.formMessagesResolver.message( + UserMessage(s"add.${desc.idString}") + ) + v => (v, s"$msg: $v") + }) + SelectField( desc, - initialValue.flatMap(i => - options.find(_.value == i).map(o => (o.id, o.label)) - ), - options.map(o => (o.id, o.label)), + initialValue.map(i => (id(i), label(i))), + options.map(o => (id(o), label(o))), validated, rawValue.writer.contramapSome, - combo + combo, + addValue ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala index 303190c..1be6ea9 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -42,7 +42,7 @@ override def build(initialValue: Option[B]): FormComponent[B] = form.build(initialValue.map(g)).map(f) - case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + case class Input[A](desc: FieldDescriptor)(using FieldBuilder[A])(using fctx: FormBuilderContext ) extends Form[A]: override def build(initialValue: Option[A]): FormComponent[A] = diff --git a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala index e120d49..ae055bc 100644 --- a/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala +++ b/tapir/shared/src/main/scala/works/iterative/tapir/codecs/Codecs.scala @@ -27,13 +27,20 @@ ): JsonCodec[T] = JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + def validatedStringDecoder[A]( + factory: ValidatedStringFactory[A] + ): JsonDecoder[A] = + JsonDecoder.string.mapOrFail(factory.apply andThen fromValidation) + + def validatedStringEncoder[A]( + factory: ValidatedStringFactory[A] + ): JsonEncoder[A] = + JsonEncoder.string.contramap(factory.getter) + def validatedStringCodec[A]( factory: ValidatedStringFactory[A] ): JsonCodec[A] = - JsonCodec.string.transformOrFail( - factory.apply andThen fromValidation, - factory.getter - ) + JsonCodec(validatedStringEncoder(factory), validatedStringDecoder(factory)) given fromValidatedStringCodec[A](using factory: ValidatedStringFactory[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 index 3fc783a..8030789 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 @@ -10,12 +10,21 @@ import scala.util.NotGiven -case class ChoiceOption[A](id: String, label: String, value: A) - case class Choice[A]( - options: List[ChoiceOption[A]], - combo: Boolean = false -) + options: List[A], + id: A => String, + label: A => String, + combo: Boolean = false, + add: Option[String => Validated[A]] = None +): + def optional: Choice[Option[A]] = + Choice[Option[A]]( + options = None :: options.map(Some(_)), + id = _.map(id).getOrElse(""), + label = _.map(label).getOrElse(""), + combo = combo, + add = add.map(_.andThen(_.map(Some(_)))) + ) trait FieldBuilder[A]: def required: Boolean @@ -119,15 +128,10 @@ fieldDescriptor: FieldDescriptor, initialValue: Option[A] ): FormComponent[A] = - val choice = summon[Choice[A]] - val options = choice.options - val combo = choice.combo ChoiceField( fieldDescriptor, initialValue, - Validations.requiredA(fieldDescriptor.label)(_), - options, - combo + Validations.requiredA(fieldDescriptor.label)(_) ) given optionalChoiceInput[A, B](using Choice[A], FormBuilderContext)(using @@ -140,17 +144,11 @@ 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) :: options.map(o => - o.copy(value = Some(o.value)) - ), - combo - ) + a => Validation.succeed(a.flatten) + )(using choice.optional) class Input[A]( desc: FieldDescriptor, @@ -189,30 +187,51 @@ class ChoiceField[A]( desc: FieldDescriptor, initialValue: Option[A], - validation: Option[A] => Validated[A], - options: List[ChoiceOption[A]], - combo: Boolean + validation: Option[A] => Validated[A] + )(using + choice: Choice[A] )(using fctx: FormBuilderContext) extends FormComponent[A]: + private val Choice(options, id, label, combo, add) = choice + + private def findValue(i: String): Option[A] = options.find(id(_) == i) + private val rawValue: Var[Option[String]] = Var( - initialValue.flatMap(i => options.find(_.value == i).map(_.id)) + initialValue.map(id(_)) ) + private def selectValue(id: Option[String]): Validated[Option[A]] = + def constructValue(id: String): Validated[Option[A]] = + add.fold(Validation.succeed(None))(_(id).map(Some(_))) + + def findOrConstructValue(id: String): Validated[Option[A]] = + findValue(id) + .map(v => Validation.succeed(Some(v))) + .getOrElse(constructValue(id)) + + id.fold(Validation.succeed(None))(findOrConstructValue) + override val validated: Signal[Validated[A]] = rawValue.signal - .map(_.flatMap(i => options.find(_.id == i).map(_.value))) - .map(validation) + .map(selectValue) + .map(_.flatMap(validation)) override val elements: Seq[HtmlElement] = + val addValue: Option[String => (String, String)] = add.map(_ => { + val msg = fctx.formMessagesResolver.message( + UserMessage(s"add.${desc.idString}") + ) + v => (v, s"$msg: $v") + }) + SelectField( desc, - initialValue.flatMap(i => - options.find(_.value == i).map(o => (o.id, o.label)) - ), - options.map(o => (o.id, o.label)), + initialValue.map(i => (id(i), label(i))), + options.map(o => (id(o), label(o))), validated, rawValue.writer.contramapSome, - combo + combo, + addValue ).elements def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala index 303190c..1be6ea9 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Form.scala @@ -42,7 +42,7 @@ override def build(initialValue: Option[B]): FormComponent[B] = form.build(initialValue.map(g)).map(f) - case class Input[A: FieldBuilder](desc: FieldDescriptor)(using + case class Input[A](desc: FieldDescriptor)(using FieldBuilder[A])(using fctx: FormBuilderContext ) extends Form[A]: override def build(initialValue: Option[A]): FormComponent[A] = 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 index 0d0ec07..654068f 100644 --- 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 @@ -12,7 +12,8 @@ options: List[(String, String)], validated: Signal[Validated[_]], observer: Observer[String], - combo: Boolean + combo: Boolean, + add: Option[String => (String, String)] )(using fctx: FormBuilderContext): val hadFocus: Var[Boolean] = Var(false) @@ -49,6 +50,7 @@ onBlur.mapTo(true) --> touched.writer ) else + val addedOpt: Var[Option[(String, String)]] = Var(None) Combobox[(String, String)](initialValue)( fctx.formUIFactory.combobox .container( @@ -67,11 +69,20 @@ 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.query.combineWithFn(addedOpt.signal) { (v, added) => + val search = v.toLowerCase() + val opts = (added.toList ++ options) + .filter(_._2.toLowerCase.contains(search)) + add match + case Some(f) if opts.isEmpty && v.trim.nonEmpty => + f(v) :: opts + case _ => opts + } --> Combobox.ctx.itemsWriter, - Combobox.ctx.value.map(_.map(_._1).getOrElse("")) --> observer + Combobox.ctx.value.map(_.map(_._1).getOrElse("")) --> observer, + Combobox.ctx.value.changes.filterNot( + _.exists(v => options.exists(o => o._1 == v._1)) + ) --> addedOpt.writer ) )