diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, A] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, 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 6b0ebcd..8082556 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 @@ -11,6 +11,10 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport trait FieldBuilder[A]: def required: Boolean @@ -21,119 +25,176 @@ 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) + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) - class StringInputField( + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + class InputField[A]( desc: FieldDescriptor, - initialValue: Option[String] = None + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[String]: + extends FormComponent[A]: 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 validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.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) - ) - ) - ) - ) + renderInputField( + desc, + initialValue, + validated, + rawValue.writer ) - given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with - override def required: Boolean = false - override def build( - fieldDescriptor: FieldDescriptor, - initialValue: Option[Option[String]] - ): FormComponent[Option[String]] = - OptionStringInputField(fieldDescriptor, initialValue) - - class OptionStringInputField( + class FileField[A]( desc: FieldDescriptor, - initialValue: Option[Option[String]] = None + validation: Option[FileList] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[Option[String]]: - private val rawValue: Var[Option[String]] = Var(None) - private val hadFocus: Var[Boolean] = Var(false) - private val touched: Var[Boolean] = Var(false) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) - override val validated: Signal[Validated[Option[String]]] = - rawValue.signal.map { - case Some(value) if value.trim.nonEmpty => - Validation.succeed(Some(value)) - case _ => Validation.succeed(None) - } + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) + renderFileInputField(desc, rawValue.writer.contramapSome) - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.flatten.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) + 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 + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.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) ) ) - ) + ) ) + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.label)()( + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, 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 6b0ebcd..8082556 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 @@ -11,6 +11,10 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport trait FieldBuilder[A]: def required: Boolean @@ -21,119 +25,176 @@ 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) + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) - class StringInputField( + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + class InputField[A]( desc: FieldDescriptor, - initialValue: Option[String] = None + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[String]: + extends FormComponent[A]: 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 validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.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) - ) - ) - ) - ) + renderInputField( + desc, + initialValue, + validated, + rawValue.writer ) - given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with - override def required: Boolean = false - override def build( - fieldDescriptor: FieldDescriptor, - initialValue: Option[Option[String]] - ): FormComponent[Option[String]] = - OptionStringInputField(fieldDescriptor, initialValue) - - class OptionStringInputField( + class FileField[A]( desc: FieldDescriptor, - initialValue: Option[Option[String]] = None + validation: Option[FileList] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[Option[String]]: - private val rawValue: Var[Option[String]] = Var(None) - private val hadFocus: Var[Boolean] = Var(false) - private val touched: Var[Boolean] = Var(false) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) - override val validated: Signal[Validated[Option[String]]] = - rawValue.signal.map { - case Some(value) if value.trim.nonEmpty => - Validation.succeed(Some(value)) - case _ => Validation.succeed(None) - } + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) + renderFileInputField(desc, rawValue.writer.contramapSome) - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.flatten.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) + 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 + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.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) ) ) - ) + ) ) + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.label)()( + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index d597162..c614d3c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -3,6 +3,7 @@ import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal +import works.iterative.core.Validated trait FormComponent[A]: def validated: Signal[Validated[A]] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, 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 6b0ebcd..8082556 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 @@ -11,6 +11,10 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport trait FieldBuilder[A]: def required: Boolean @@ -21,119 +25,176 @@ 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) + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) - class StringInputField( + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + class InputField[A]( desc: FieldDescriptor, - initialValue: Option[String] = None + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[String]: + extends FormComponent[A]: 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 validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.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) - ) - ) - ) - ) + renderInputField( + desc, + initialValue, + validated, + rawValue.writer ) - given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with - override def required: Boolean = false - override def build( - fieldDescriptor: FieldDescriptor, - initialValue: Option[Option[String]] - ): FormComponent[Option[String]] = - OptionStringInputField(fieldDescriptor, initialValue) - - class OptionStringInputField( + class FileField[A]( desc: FieldDescriptor, - initialValue: Option[Option[String]] = None + validation: Option[FileList] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[Option[String]]: - private val rawValue: Var[Option[String]] = Var(None) - private val hadFocus: Var[Boolean] = Var(false) - private val touched: Var[Boolean] = Var(false) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) - override val validated: Signal[Validated[Option[String]]] = - rawValue.signal.map { - case Some(value) if value.trim.nonEmpty => - Validation.succeed(Some(value)) - case _ => Validation.succeed(None) - } + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) + renderFileInputField(desc, rawValue.writer.contramapSome) - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.flatten.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) + 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 + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.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) ) ) - ) + ) ) + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.label)()( + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index d597162..c614d3c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -3,6 +3,7 @@ import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal +import works.iterative.core.Validated trait FormComponent[A]: def validated: Signal[Validated[A]] 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 467a97c..c394907 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 @@ -34,3 +34,9 @@ def errorTextMods: HtmlMod def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String, icon: Option[SvgElement] = None)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, 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 6b0ebcd..8082556 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 @@ -11,6 +11,10 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport trait FieldBuilder[A]: def required: Boolean @@ -21,119 +25,176 @@ 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) + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) - class StringInputField( + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + class InputField[A]( desc: FieldDescriptor, - initialValue: Option[String] = None + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[String]: + extends FormComponent[A]: 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 validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.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) - ) - ) - ) - ) + renderInputField( + desc, + initialValue, + validated, + rawValue.writer ) - given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with - override def required: Boolean = false - override def build( - fieldDescriptor: FieldDescriptor, - initialValue: Option[Option[String]] - ): FormComponent[Option[String]] = - OptionStringInputField(fieldDescriptor, initialValue) - - class OptionStringInputField( + class FileField[A]( desc: FieldDescriptor, - initialValue: Option[Option[String]] = None + validation: Option[FileList] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[Option[String]]: - private val rawValue: Var[Option[String]] = Var(None) - private val hadFocus: Var[Boolean] = Var(false) - private val touched: Var[Boolean] = Var(false) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) - override val validated: Signal[Validated[Option[String]]] = - rawValue.signal.map { - case Some(value) if value.trim.nonEmpty => - Validation.succeed(Some(value)) - case _ => Validation.succeed(None) - } + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) + renderFileInputField(desc, rawValue.writer.contramapSome) - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.flatten.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) + 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 + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.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) ) ) - ) + ) ) + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.label)()( + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index d597162..c614d3c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -3,6 +3,7 @@ import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal +import works.iterative.core.Validated trait FormComponent[A]: def validated: Signal[Validated[A]] 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 467a97c..c394907 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 @@ -34,3 +34,9 @@ def errorTextMods: HtmlMod def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String, icon: Option[SvgElement] = None)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +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.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, 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 6b0ebcd..8082556 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 @@ -11,6 +11,10 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport trait FieldBuilder[A]: def required: Boolean @@ -21,119 +25,176 @@ 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) + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) - class StringInputField( + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + class InputField[A]( desc: FieldDescriptor, - initialValue: Option[String] = None + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[String]: + extends FormComponent[A]: 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 validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.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) - ) - ) - ) - ) + renderInputField( + desc, + initialValue, + validated, + rawValue.writer ) - given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with - override def required: Boolean = false - override def build( - fieldDescriptor: FieldDescriptor, - initialValue: Option[Option[String]] - ): FormComponent[Option[String]] = - OptionStringInputField(fieldDescriptor, initialValue) - - class OptionStringInputField( + class FileField[A]( desc: FieldDescriptor, - initialValue: Option[Option[String]] = None + validation: Option[FileList] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[Option[String]]: - private val rawValue: Var[Option[String]] = Var(None) - private val hadFocus: Var[Boolean] = Var(false) - private val touched: Var[Boolean] = Var(false) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) - override val validated: Signal[Validated[Option[String]]] = - rawValue.signal.map { - case Some(value) if value.trim.nonEmpty => - Validation.succeed(Some(value)) - case _ => Validation.succeed(None) - } + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) + renderFileInputField(desc, rawValue.writer.contramapSome) - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.flatten.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) + 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 + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.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) ) ) - ) + ) ) + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.label)()( + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index d597162..c614d3c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -3,6 +3,7 @@ import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal +import works.iterative.core.Validated trait FormComponent[A]: def validated: Signal[Validated[A]] 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 467a97c..c394907 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 @@ -34,3 +34,9 @@ def errorTextMods: HtmlMod def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String, icon: Option[SvgElement] = None)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +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.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..62756d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, 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 6b0ebcd..8082556 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 @@ -11,6 +11,10 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport trait FieldBuilder[A]: def required: Boolean @@ -21,119 +25,176 @@ 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) + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) - class StringInputField( + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + class InputField[A]( desc: FieldDescriptor, - initialValue: Option[String] = None + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[String]: + extends FormComponent[A]: 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 validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.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) - ) - ) - ) - ) + renderInputField( + desc, + initialValue, + validated, + rawValue.writer ) - given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with - override def required: Boolean = false - override def build( - fieldDescriptor: FieldDescriptor, - initialValue: Option[Option[String]] - ): FormComponent[Option[String]] = - OptionStringInputField(fieldDescriptor, initialValue) - - class OptionStringInputField( + class FileField[A]( desc: FieldDescriptor, - initialValue: Option[Option[String]] = None + validation: Option[FileList] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[Option[String]]: - private val rawValue: Var[Option[String]] = Var(None) - private val hadFocus: Var[Boolean] = Var(false) - private val touched: Var[Boolean] = Var(false) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) - override val validated: Signal[Validated[Option[String]]] = - rawValue.signal.map { - case Some(value) if value.trim.nonEmpty => - Validation.succeed(Some(value)) - case _ => Validation.succeed(None) - } + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) + renderFileInputField(desc, rawValue.writer.contramapSome) - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.flatten.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) + 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 + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.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) ) ) - ) + ) ) + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.label)()( + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index d597162..c614d3c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -3,6 +3,7 @@ import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal +import works.iterative.core.Validated trait FormComponent[A]: def validated: Signal[Validated[A]] 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 467a97c..c394907 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 @@ -34,3 +34,9 @@ def errorTextMods: HtmlMod def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String, icon: Option[SvgElement] = None)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +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.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..62756d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) 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 index a4a0c71..466a79c 100644 --- 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 @@ -6,5 +6,3 @@ package object forms: type FieldId = String type FieldLabel = String - - type Validated[A] = Validation[UserMessage, A] diff --git a/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..858a50f --- /dev/null +++ b/core/js/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = org.scalajs.dom.File + + extension (f: FileRepr) def name: String = f.name diff --git a/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala new file mode 100644 index 0000000..a179a80 --- /dev/null +++ b/core/jvm/src/main/scala/works/iterative/core/FileSupportPlatformSpecific.scala @@ -0,0 +1,6 @@ +package works.iterative.core + +trait FileSupportPlatformSpecific: + type FileRepr = java.io.File + + extension (f: FileRepr) def name: String = f.getName diff --git a/core/shared/src/main/scala/works/iterative/core/Email.scala b/core/shared/src/main/scala/works/iterative/core/Email.scala new file mode 100644 index 0000000..1bd3bcb --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Email.scala @@ -0,0 +1,12 @@ +package works.iterative.core + +import zio.prelude.* + +opaque type Email = String + +object Email: + def apply(value: String): Validated[Email] = + // TODO: email validation + Validation.succeed(value) + + extension (email: Email) def value: String = email diff --git a/core/shared/src/main/scala/works/iterative/core/FileSupport.scala b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala new file mode 100644 index 0000000..c961045 --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/FileSupport.scala @@ -0,0 +1,3 @@ +package works.iterative.core + +object FileSupport extends FileSupportPlatformSpecific diff --git a/core/shared/src/main/scala/works/iterative/core/Text.scala b/core/shared/src/main/scala/works/iterative/core/Text.scala index cccaf59..7b9ef2d 100644 --- a/core/shared/src/main/scala/works/iterative/core/Text.scala +++ b/core/shared/src/main/scala/works/iterative/core/Text.scala @@ -16,9 +16,9 @@ def validateNonEmpty[T](text: T)(using ev: T =:= String - ): Validation[MessageId, T] = - Validation.fromPredicateWith[MessageId, T]( - "validation.text.empty" + ): Validated[T] = + Validation.fromPredicateWith[UserMessage, T]( + UserMessage("validation.text.empty") )(text)(t => ev(t).nonEmpty) def firstNewLine(t: String): Int = @@ -36,10 +36,10 @@ opaque type PlainMultiLine = String object PlainMultiLine: - def apply(text: String): Validation[MessageId, PlainMultiLine] = + def apply(text: String): Validated[PlainMultiLine] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + def opt(text: String): Validated[Option[PlainMultiLine]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[PlainMultiLine] = @@ -60,23 +60,23 @@ : Conversion[Option[PlainMultiLine], Option[String]] with def apply(p: Option[PlainMultiLine]): Option[String] = p.map(_.toString) - extension (p: PlainMultiLine) def toString: String = p + extension (p: PlainMultiLine) def asString: String = p opaque type PlainOneLine = String object PlainOneLine: - def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = - Validation.fromPredicateWith[MessageId, PlainOneLine]( - "validation.text.oneline" + def validateOneLine(text: PlainOneLine): Validated[PlainOneLine] = + Validation.fromPredicateWith[UserMessage, PlainOneLine]( + UserMessage("validation.text.oneline") )(text)(!Text.hasNewLine(_)) - def apply(text: String): Validation[MessageId, PlainOneLine] = + def apply(text: String): Validated[PlainOneLine] = for _ <- Text.validateNonEmpty(text) _ <- validateOneLine(text) yield text - def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + def opt(text: String): Validated[Option[PlainOneLine]] = for _ <- validateOneLine(text) yield Text.nonEmpty(text) @@ -96,10 +96,10 @@ opaque type Markdown = String object Markdown: - def apply(text: String): Validation[MessageId, Markdown] = + def apply(text: String): Validated[Markdown] = Text.validateNonEmpty(text) - def opt(text: String): Validation[Nothing, Option[Markdown]] = + def opt(text: String): Validated[Option[Markdown]] = Validation.succeed(optDirect(text)) def optDirect(text: String): Option[Markdown] = diff --git a/core/shared/src/main/scala/works/iterative/core/Validated.scala b/core/shared/src/main/scala/works/iterative/core/Validated.scala new file mode 100644 index 0000000..2e3a4ee --- /dev/null +++ b/core/shared/src/main/scala/works/iterative/core/Validated.scala @@ -0,0 +1,5 @@ +package works.iterative.core + +import zio.prelude.Validation + +type Validated[A] = Validation[UserMessage, 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 6b0ebcd..8082556 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 @@ -11,6 +11,10 @@ import works.iterative.ui.components.laminar.HtmlRenderable.given import works.iterative.ui.components.ComponentContext import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import org.scalajs.dom.FileList +import works.iterative.core.FileSupport trait FieldBuilder[A]: def required: Boolean @@ -21,119 +25,176 @@ 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) + // TODO: use validation codec with A => raw string and raw string => Validted[A] + def requiredInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[A] = + new FieldBuilder[A]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[A] + ): FormComponent[A] = + val codec = summon[InputCodec[A]] + InputField( + fieldDescriptor, + initialValue.map(codec.encode), + Validations.required(fieldDescriptor.label)(_).flatMap(codec.decode) + ) - class StringInputField( + def optionalInput[A: InputCodec](using + fctx: FormBuilderContext + ): FieldBuilder[Option[A]] = + new FieldBuilder[Option[A]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[A]] + ): FormComponent[Option[A]] = + val codec = summon[InputCodec[A]] + InputField[Option[A]]( + fieldDescriptor, + initialValue.flatten.map(codec.encode), + (v: Option[String]) => + v match + case Some(s) if s.trim.nonEmpty => codec.decode(s).map(Some(_)) + case _ => Validation.succeed(None) + ) + + given [A: InputCodec](using + fctx: FormBuilderContext, + ev: NotGiven[A <:< Option[_]] + ): FieldBuilder[A] = requiredInput[A] + + given [A, B: InputCodec](using + fctx: FormBuilderContext, + ev: A <:< Option[B] + ): FieldBuilder[Option[B]] = optionalInput[B] + + given optionalFileInput(using + FormBuilderContext + ): FieldBuilder[Option[FileSupport.FileRepr]] = + new FieldBuilder[Option[FileSupport.FileRepr]]: + override def required: Boolean = false + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[Option[FileSupport.FileRepr]] + ): FormComponent[Option[FileSupport.FileRepr]] = + FileField( + fieldDescriptor, + _ match { + case Some(files) => Validation.succeed(files.headOption) + case None => Validation.succeed(None) + } + ) + + given fileInput(using + FormBuilderContext + ): FieldBuilder[FileSupport.FileRepr] = + new FieldBuilder[FileSupport.FileRepr]: + override def required: Boolean = true + override def build( + fieldDescriptor: FieldDescriptor, + initialValue: Option[FileSupport.FileRepr] + ): FormComponent[FileSupport.FileRepr] = + FileField( + fieldDescriptor, + _.flatMap(_.headOption) match { + case Some(file) => Validation.succeed(file) + case None => + Validation.fail( + UserMessage("error.file.required", fieldDescriptor.label) + ) + } + ) + + class InputField[A]( desc: FieldDescriptor, - initialValue: Option[String] = None + initialValue: Option[String] = None, + validation: Option[String] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[String]: + extends FormComponent[A]: 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 validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) - - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) - - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.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) - ) - ) - ) - ) + renderInputField( + desc, + initialValue, + validated, + rawValue.writer ) - given (using fctx: FormBuilderContext): FieldBuilder[Option[String]] with - override def required: Boolean = false - override def build( - fieldDescriptor: FieldDescriptor, - initialValue: Option[Option[String]] - ): FormComponent[Option[String]] = - OptionStringInputField(fieldDescriptor, initialValue) - - class OptionStringInputField( + class FileField[A]( desc: FieldDescriptor, - initialValue: Option[Option[String]] = None + validation: Option[FileList] => Validated[A] )(using fctx: FormBuilderContext) - extends FormComponent[Option[String]]: - private val rawValue: Var[Option[String]] = Var(None) - private val hadFocus: Var[Boolean] = Var(false) - private val touched: Var[Boolean] = Var(false) + extends FormComponent[A]: + private val rawValue: Var[Option[FileList]] = Var(None) - override val validated: Signal[Validated[Option[String]]] = - rawValue.signal.map { - case Some(value) if value.trim.nonEmpty => - Validation.succeed(Some(value)) - case _ => Validation.succeed(None) - } + override val validated: Signal[Validated[A]] = + rawValue.signal.map(validation) override val elements: Seq[HtmlElement] = - val hasError: Signal[Boolean] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_ => true, _ => false) else false - ) + renderFileInputField(desc, rawValue.writer.contramapSome) - val errors: Signal[List[UserMessage]] = - validated.combineWithFn(touched.signal)((v, t) => - if t then v.fold(_.toList, _ => List.empty) else Nil - ) + def renderInputField( + desc: FieldDescriptor, + initialValue: Option[String], + validated: Signal[Validated[_]], + observer: Observer[Option[String]] + )(using fctx: FormBuilderContext): Seq[HtmlElement] = - Seq( - div( - fctx.formUIFactory.input(hasError)( - idAttr(desc.idString), - nameAttr(desc.name), - desc.placeholder.map(placeholder(_)), - initialValue.flatten.map(L.value(_)), - onInput.mapToValue.setAsValue --> rawValue.writer.contramap { - (v: String) => Option(v).map(_.trim).filter(_.nonEmpty) - }, - onFocus.mapTo(true) --> hadFocus.writer, - onBlur.mapTo(true) --> touched.writer - ), - children <-- errors - .map( - _.map[HtmlElement](msg => - fctx.formUIFactory.validationError( - fctx.formMessagesResolver.message(msg) - ) + 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 + ) + + Seq( + div( + fctx.formUIFactory.input(hasError)( + idAttr(desc.idString), + nameAttr(desc.name), + desc.placeholder.map(placeholder(_)), + initialValue.map(L.value(_)), + onInput.mapToValue.setAsValue --> observer.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) ) ) - ) + ) ) + ) + + def renderFileInputField(desc: FieldDescriptor, observer: Observer[FileList])( + using fctx: FormBuilderContext + ): Seq[HtmlElement] = + Seq( + div( + fctx.formUIFactory + .fileInput(desc.label)()( + inContext(thisNode => + onInput.mapTo(thisNode.ref.files) --> observer + ) + ) + ) + ) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala index d597162..c614d3c 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/FormComponent.scala @@ -3,6 +3,7 @@ import zio.prelude.* import com.raquo.laminar.api.L.{*, given} import com.raquo.airstream.core.Signal +import works.iterative.core.Validated trait FormComponent[A]: def validated: Signal[Validated[A]] 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 467a97c..c394907 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 @@ -34,3 +34,9 @@ def errorTextMods: HtmlMod def input(inError: Signal[Boolean])(mods: HtmlMod*): HtmlElement + + def fileInput(title: String, icon: Option[SvgElement] = None)( + buttonMods: HtmlMod* + )( + inputMods: Mod[ReactiveHtmlElement[org.scalajs.dom.HTMLInputElement]]* + ): HtmlElement diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala new file mode 100644 index 0000000..1529a68 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/InputCodec.scala @@ -0,0 +1,34 @@ +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.laminar.HtmlRenderable.given +import works.iterative.ui.components.ComponentContext +import works.iterative.core.MessageCatalogue +import works.iterative.core.Validated +import scala.util.NotGiven +import works.iterative.core.Email + +trait InputCodec[A]: + def encode(a: A): String + def decode(s: String): Validated[A] + +object InputCodec: + given InputCodec[String] with + override def encode(a: String): String = a + override def decode(s: String): Validated[String] = Validation.succeed(s) + + given InputCodec[PlainMultiLine] with + override def encode(a: PlainMultiLine): String = a.asString + override def decode(s: String): Validated[PlainMultiLine] = + PlainMultiLine(s) + + given InputCodec[Email] with + def encode(a: Email): String = a.value + def decode(s: String): Validated[Email] = Email(s) diff --git a/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala new file mode 100644 index 0000000..62756d0 --- /dev/null +++ b/ui/js/src/main/scala/works/iterative/ui/components/laminar/forms/Validations.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.laminar.forms + +import zio.prelude.* +import works.iterative.core.UserMessage +import works.iterative.core.Validated + +object Validations: + + def required(label: String): Option[String] => Validated[String] = + case Some(value) if value.trim.nonEmpty => Validation.succeed(value) + case _ => + Validation.fail(UserMessage("error.value.required", label)) 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 index a4a0c71..466a79c 100644 --- 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 @@ -6,5 +6,3 @@ package object forms: type FieldId = String type FieldLabel = String - - type Validated[A] = Validation[UserMessage, A] diff --git a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala index 2f56234..af958ed 100644 --- a/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala +++ b/ui/js/src/main/scala/works/iterative/ui/components/tailwind/form/FormCodec.scala @@ -37,7 +37,7 @@ case Some(t) => t.toString case _ => "" override def toValue(r: String): Validated[Option[PlainMultiLine]] = - PlainMultiLine.opt(r) + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) given optionLocalDateCodec: FormCodec[Option[LocalDate], String] with val df = DateTimeFormatter.ofPattern("yyyy-MM-dd")