diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..2504cdd --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,6 @@ +package works.iterative +package ui.components.tailwind.form + +import core.MessageId + +case class InvalidValue(message: MessageId) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..2504cdd --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,6 @@ +package works.iterative +package ui.components.tailwind.form + +import core.MessageId + +case class InvalidValue(message: MessageId) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..2504cdd --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,6 @@ +package works.iterative +package ui.components.tailwind.form + +import core.MessageId + +case class InvalidValue(message: MessageId) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..8ed7541 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,28 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V]( + initialRows: Int = 5 +)(using codec: FormCodec[V]) + extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val currentValue = prop.value.map(codec.toForm) + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(initialRows)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events.map(codec.toValue) --> updates, + name := prop.name, + rows <-- rowNo.signal.map(_ + 2), + cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..2504cdd --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,6 @@ +package works.iterative +package ui.components.tailwind.form + +import core.MessageId + +case class InvalidValue(message: MessageId) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..8ed7541 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,28 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V]( + initialRows: Int = 5 +)(using codec: FormCodec[V]) + extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val currentValue = prop.value.map(codec.toForm) + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(initialRows)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events.map(codec.toValue) --> updates, + name := prop.name, + rows <-- rowNo.signal.map(_ + 2), + cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..2504cdd --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,6 @@ +package works.iterative +package ui.components.tailwind.form + +import core.MessageId + +case class InvalidValue(message: MessageId) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..8ed7541 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,28 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V]( + initialRows: Int = 5 +)(using codec: FormCodec[V]) + extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val currentValue = prop.value.map(codec.toForm) + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(initialRows)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events.map(codec.toValue) --> updates, + name := prop.name, + rows <-- rowNo.signal.map(_ + 2), + cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/model/src/Form.scala b/ui/model/src/Form.scala index d9a978f..9ad7f99 100644 --- a/ui/model/src/Form.scala +++ b/ui/model/src/Form.scala @@ -1,16 +1,18 @@ package works.iterative package ui.model +import core.* + case class FormItem[Value]( id: String, - label: OneLine, - description: Option[Paragraph], + label: PlainOneLine, + description: Option[PlainMultiLine], value: Value ) case class FormSection( - header: OneLine, - description: Option[Paragraph], + header: PlainOneLine, + description: Option[PlainMultiLine], items: List[FormItem[_]] ) diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..2504cdd --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,6 @@ +package works.iterative +package ui.components.tailwind.form + +import core.MessageId + +case class InvalidValue(message: MessageId) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..8ed7541 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,28 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V]( + initialRows: Int = 5 +)(using codec: FormCodec[V]) + extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val currentValue = prop.value.map(codec.toForm) + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(initialRows)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events.map(codec.toValue) --> updates, + name := prop.name, + rows <-- rowNo.signal.map(_ + 2), + cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/model/src/Form.scala b/ui/model/src/Form.scala index d9a978f..9ad7f99 100644 --- a/ui/model/src/Form.scala +++ b/ui/model/src/Form.scala @@ -1,16 +1,18 @@ package works.iterative package ui.model +import core.* + case class FormItem[Value]( id: String, - label: OneLine, - description: Option[Paragraph], + label: PlainOneLine, + description: Option[PlainMultiLine], value: Value ) case class FormSection( - header: OneLine, - description: Option[Paragraph], + header: PlainOneLine, + description: Option[PlainMultiLine], items: List[FormItem[_]] ) diff --git a/ui/model/src/ItemList.scala b/ui/model/src/ItemList.scala index 3aa7815..76e9dac 100644 --- a/ui/model/src/ItemList.scala +++ b/ui/model/src/ItemList.scala @@ -1,21 +1,24 @@ -package works.iterative.ui.model +package works.iterative +package ui.model + +import core.* case class ItemList( items: List[ListSection] ) case class ItemProp( - text: OneLine, + text: PlainOneLine, icon: Option[Icon] = None ) case class ListSection( - title: OneLine, + title: PlainOneLine, items: List[ListItem] ) case class ListItem( - title: OneLine, + title: PlainOneLine, href: String, label: Option[Label] = None, leftProps: List[ItemProp] = Nil, diff --git a/bom.sc b/bom.sc index f4e28ed..930a6d3 100644 --- a/bom.sc +++ b/bom.sc @@ -15,6 +15,7 @@ val pac4j = "5.2.0" val play = "2.8.8" val playJson = "2.9.2" + val refined = "0.9.29" val scalaTest = "3.2.9" val slf4j = "1.7.36" val slick = "3.3.3" @@ -62,6 +63,8 @@ lazy val catsCore: Dep = ivy"org.typelevel::cats-core::${V.cats}" + lazy val refined: Dep = ivy"eu.timepit::refined::${V.refined}" + /* What is the equivalent? ZIOModule with prepared test config? def useZIO(testConf: Configuration*): Agg[Dep] = Agg( zio, diff --git a/codecs/src/Codecs.scala b/codecs/src/Codecs.scala new file mode 100644 index 0000000..6a66a32 --- /dev/null +++ b/codecs/src/Codecs.scala @@ -0,0 +1,34 @@ +package works.iterative +package core +package codecs + +import zio.json.* +import zio.prelude.Validation +import works.iterative.tapir.CustomTapir + +private[codecs] case class TextEncoding( + pml: Option[PlainMultiLine], + pon: Option[PlainOneLine], + md: Option[Markdown] +) + +trait Codecs extends JsonCodecs with TapirCodecs + +trait JsonCodecs: + + def fromValidation[T](v: Validation[MessageId, T]): Either[String, T] = + v.mapError(_.toString).toEither.left.map(_.mkString(",")) + + private def textCodec[T]( + f: String => Validation[MessageId, T] + ): JsonCodec[T] = + JsonCodec.string.transformOrFail(f andThen fromValidation, _.toString) + + given JsonCodec[PlainMultiLine] = textCodec(PlainMultiLine.apply) + given JsonCodec[PlainOneLine] = textCodec(PlainOneLine.apply) + given JsonCodec[Markdown] = textCodec(Markdown.apply) + +trait TapirCodecs extends CustomTapir: + given Schema[PlainMultiLine] = Schema.string + given Schema[PlainOneLine] = Schema.string + given Schema[Markdown] = Schema.string diff --git a/core/src/MessageCatalogue.scala b/core/src/MessageCatalogue.scala new file mode 100644 index 0000000..b5daf9f --- /dev/null +++ b/core/src/MessageCatalogue.scala @@ -0,0 +1,5 @@ +package works.iterative +package core + +trait MessageCatalogue: + def apply(id: MessageId): Option[String] diff --git a/core/src/MessageId.scala b/core/src/MessageId.scala new file mode 100644 index 0000000..72c4777 --- /dev/null +++ b/core/src/MessageId.scala @@ -0,0 +1,18 @@ +package works.iterative +package core + +// TODO: use refined's NonEmptyString after macros are ported to Scala 3 +// https://github.com/fthomas/refined/issues/932 +// We could than use the inlined macro to validate non empty strings at runtime + +/* MessageId is an opaque type to mark all keys to translations. + * The intent is to use it during build to generate a list of all keys + * and lately to check if we have all the translations we need + */ +opaque type MessageId = String + +object MessageId: + def apply(id: String): MessageId = id + + inline given Conversion[String, MessageId] with + inline def apply(id: String): MessageId = MessageId(id) diff --git a/core/src/Text.scala b/core/src/Text.scala new file mode 100644 index 0000000..950bb74 --- /dev/null +++ b/core/src/Text.scala @@ -0,0 +1,90 @@ +package works.iterative +package core + +import scala.jdk.OptionConverters.given +import zio.prelude.Validation + +/* UserText represents different, more specific variants of String to be able to + * better identify what kind of String are we looking It is meant to be used + * primarily for UI, but sometimes it is useful to store the different text + * representations variantly, eg. it is useful to have the distinction even on + * the level of the data model, not just in the UI + */ +object Text: + + def nonEmpty(s: String): Option[String] = Option(s).filterNot(_.trim.isEmpty) + + def validateNonEmpty[T](text: T)(using + ev: T =:= String + ): Validation[MessageId, T] = + Validation.fromPredicateWith[MessageId, T]( + "validation.text.empty" + )(text)(t => ev(t).nonEmpty) + + def firstNewLine(t: String): Int = + val lf = t.indexOf('\n') + def cr = t.indexOf('\r') + if lf != -1 then lf else cr + + def hasNewLine(t: String): Boolean = firstNewLine(t) != -1 + + def firstLineOf(t: String): String = + firstNewLine(t) match + case -1 => t + case i => t.take(i) + +opaque type PlainMultiLine = String + +object PlainMultiLine: + def apply(text: String): Validation[MessageId, PlainMultiLine] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[PlainMultiLine]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[PlainMultiLine] = + Text.nonEmpty(text) + + extension (p: PlainMultiLine) def toString: String = p + +opaque type PlainOneLine = String + +object PlainOneLine: + def validateOneLine(text: PlainOneLine): Validation[MessageId, PlainOneLine] = + Validation.fromPredicateWith[MessageId, PlainOneLine]( + "validation.text.oneline" + )(text)(!Text.hasNewLine(_)) + + def apply(text: String): Validation[MessageId, PlainOneLine] = + for + _ <- Text.validateNonEmpty(text) + _ <- validateOneLine(text) + yield text + + def opt(text: String): Validation[MessageId, Option[PlainOneLine]] = + for _ <- validateOneLine(text) + yield Text.nonEmpty(text) + + def optFirstLine(text: String): Option[PlainOneLine] = + Text.nonEmpty(Text.firstLineOf(text)) + + def firstLine(text: String, default: => String): PlainOneLine = + optFirstLine(text).getOrElse(default) + + def firstLineEmpty(text: String): PlainOneLine = firstLine(text, "") + + extension (p: PlainOneLine) def toString: String = p + +opaque type Markdown = String + +object Markdown: + def apply(text: String): Validation[MessageId, Markdown] = + Text.validateNonEmpty(text) + + def opt(text: String): Validation[Nothing, Option[Markdown]] = + Validation.succeed(optDirect(text)) + + def optDirect(text: String): Option[Markdown] = + Text.nonEmpty(text) + + extension (p: Markdown) def toString: String = p diff --git a/ui/components/src/services/files/components/tailwind/File.scala b/ui/components/src/services/files/components/tailwind/File.scala index e6bab0a..66eb6e7 100644 --- a/ui/components/src/services/files/components/tailwind/File.scala +++ b/ui/components/src/services/files/components/tailwind/File.scala @@ -6,18 +6,17 @@ import works.iterative.ui.components.tailwind.HtmlRenderable given HtmlRenderable[File] with - extension (m: File) - def render: HtmlElement = - li( - cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), - div( - cls("w-0 flex-1 flex items-center"), - Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), - span(cls("ml-2 flex-1 w-0 truncate"), m.name) - ), - a( - href(m.url), - cls("font-medium text-indigo-600 hover:text-indigo-500"), - "Otevřít" - ) + def toHtml(m: File): HtmlElement = + li( + cls("pl-3 pr-4 py-3 flex items-center justify-between text-sm"), + div( + cls("w-0 flex-1 flex items-center"), + Icons.solid.paperclip("w-5 h-5 flex-shrink-0 text-gray-400"), + span(cls("ml-2 flex-1 w-0 truncate"), m.name) + ), + a( + href(m.url), + cls("font-medium text-indigo-600 hover:text-indigo-500"), + "Otevřít" ) + ) diff --git a/ui/components/src/ui/JsonMessageCatalogue.scala b/ui/components/src/ui/JsonMessageCatalogue.scala new file mode 100644 index 0000000..a502713 --- /dev/null +++ b/ui/components/src/ui/JsonMessageCatalogue.scala @@ -0,0 +1,12 @@ +package works.iterative +package ui + +import core.{MessageCatalogue, MessageId} +import scala.scalajs.js + +// TODO: support hierarchical json structure +trait JsonMessageCatalogue extends MessageCatalogue: + def messages: js.Dictionary[String] + + override def apply(id: MessageId): Option[String] = + messages.get(id.toString) diff --git a/ui/components/src/ui/components/tailwind/ComponentContext.scala b/ui/components/src/ui/components/tailwind/ComponentContext.scala index e1ead5f..3687acd 100644 --- a/ui/components/src/ui/components/tailwind/ComponentContext.scala +++ b/ui/components/src/ui/components/tailwind/ComponentContext.scala @@ -2,6 +2,8 @@ package ui.components.tailwind import com.raquo.airstream.core.Observer +import works.iterative.core.MessageCatalogue trait ComponentContext: def eventBus: Observer[ui.model.AppEvent] + def messages: MessageCatalogue diff --git a/ui/components/src/ui/components/tailwind/Renderable.scala b/ui/components/src/ui/components/tailwind/Renderable.scala index ec87e6b..4b80072 100644 --- a/ui/components/src/ui/components/tailwind/Renderable.scala +++ b/ui/components/src/ui/components/tailwind/Renderable.scala @@ -3,16 +3,19 @@ import com.raquo.laminar.api.L.{*, given} import org.scalajs.dom import java.time.LocalDate +import works.iterative.core.PlainMultiLine trait HtmlRenderable[A]: - extension (a: A) def render: Modifier[HtmlElement] + def toHtml(a: A): Modifier[HtmlElement] + extension (a: A) def render: Modifier[HtmlElement] = toHtml(a) object HtmlRenderable: given stringValue: HtmlRenderable[String] with - extension (v: String) - def render: Modifier[HtmlElement] = - v: Modifier[HtmlElement] + def toHtml(v: String): Modifier[HtmlElement] = + com.raquo.laminar.nodes.TextNode(v) given dateValue: HtmlRenderable[LocalDate] with - extension (v: LocalDate) - def render: Modifier[HtmlElement] = - TimeUtils.formatDate(v) + def toHtml(v: LocalDate): Modifier[HtmlElement] = + TimeUtils.formatDate(v) + given plainMultiLineValue: HtmlRenderable[PlainMultiLine] with + def toHtml(v: PlainMultiLine): Modifier[HtmlElement] = + pre(v.toString) diff --git a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala index e0e18d6..37acc1d 100644 --- a/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala +++ b/ui/components/src/ui/components/tailwind/data_display/description_lists/LeftAlignedInCard.scala @@ -25,17 +25,17 @@ ) trait AsValue[V]: - extension (v: V) def labeled(n: UIString): OptionalLabeledValue + def toLabeled(n: UIString, v: V): OptionalLabeledValue + extension (v: V) + def labeled(n: UIString): OptionalLabeledValue = toLabeled(n, v) given optionValue[V: HtmlRenderable]: AsValue[Option[V]] with - extension (v: Option[V]) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, v.map(_.render)) + def toLabeled(n: UIString, v: Option[V]): OptionalLabeledValue = + OptionalLabeledValue(n, v.map(_.render)) given [V: HtmlRenderable]: AsValue[V] with - extension (v: V) - def labeled(n: UIString): OptionalLabeledValue = - OptionalLabeledValue(n, Some(v.render)) + def toLabeled(n: UIString, v: V): OptionalLabeledValue = + OptionalLabeledValue(n, Some(v.render)) given leftAlignedInCardComponent[A](using HtmlComponent[_, ActionButtons[A]] diff --git a/ui/components/src/ui/components/tailwind/form/FormCodec.scala b/ui/components/src/ui/components/tailwind/form/FormCodec.scala new file mode 100644 index 0000000..5c78b24 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormCodec.scala @@ -0,0 +1,35 @@ +package works.iterative +package ui.components.tailwind.form + +import zio.prelude.Validation + +import scala.scalajs.js + +import core.PlainMultiLine +import java.time.format.DateTimeFormatter +import java.time.LocalDate +import scala.util.Try + +trait FormCodec[V]: + def toForm(v: V): String + def toValue(r: String): Validated[V] + +object FormCodec: + given FormCodec[PlainMultiLine] with + override def toForm(v: PlainMultiLine): String = v.toString + override def toValue(r: String): Validated[PlainMultiLine] = + PlainMultiLine(r).mapError(e => InvalidValue(e)) + + given plainMultiLineCodec: FormCodec[Option[PlainMultiLine]] with + override def toForm(v: Option[PlainMultiLine]): String = v match + case Some(t) => t.toString + case _ => "" + override def toValue(r: String): Validated[Option[PlainMultiLine]] = + PlainMultiLine.opt(r).mapError(e => InvalidValue(e)) + + given optionLocalDateCodec: FormCodec[Option[LocalDate]] with + val df = DateTimeFormatter.ofPattern("yyyy-MM-dd") + override def toForm(v: Option[LocalDate]): String = + v.map(df.format(_)).getOrElse("") + override def toValue(r: String): Validated[Option[LocalDate]] = + Validation.succeed(Try(LocalDate.parse(r, df)).toOption) diff --git a/ui/components/src/ui/components/tailwind/form/FormInput.scala b/ui/components/src/ui/components/tailwind/form/FormInput.scala new file mode 100644 index 0000000..6422870 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormInput.scala @@ -0,0 +1,20 @@ +package works.iterative +package ui.components.tailwind.form + +import core.PlainMultiLine + +import com.raquo.laminar.api.L.{*, given} +import java.time.LocalDate + +trait FormInput[V]: + def render( + property: Property[V], + updates: Observer[Validated[V]] + ): HtmlElement + +object FormInput: + given plainMultiLineInput: FormInput[PlainMultiLine] = TextArea() + given optionPlainMultiLineInput: FormInput[Option[PlainMultiLine]] = + TextArea() + given optionLocalDateInput: FormInput[Option[LocalDate]] = + Inputs.OptionDateInput() diff --git a/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala new file mode 100644 index 0000000..f6f8fa9 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/FormPropertyRow.scala @@ -0,0 +1,27 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormPropertyRow[V]( + property: FormProperty[V], + input: Property[V] => Modifier[Div] +) + +object FormPropertyRow: + extension [V](m: FormPropertyRow[V]) + def element: HtmlElement = + div( + cls := "sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5", + label( + forId := m.property.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.property.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.input(m.property), + m.property.description.map(d => + p(cls := "mt-2 text-sm text-gray-500", d) + ) + ) + ) diff --git a/ui/components/src/ui/components/tailwind/form/Inputs.scala b/ui/components/src/ui/components/tailwind/form/Inputs.scala index ce8d7d2..89bbb8d 100644 --- a/ui/components/src/ui/components/tailwind/form/Inputs.scala +++ b/ui/components/src/ui/components/tailwind/form/Inputs.scala @@ -1,26 +1,37 @@ package works.iterative package ui.components.tailwind.form -import com.raquo.laminar.api.L.{textArea => ta, *, given} -import works.iterative.ui.components.tailwind.HtmlComponent +import com.raquo.laminar.api.L.{*, given} +import scala.scalajs.js import com.raquo.laminar.nodes.ReactiveHtmlElement -import works.iterative.ui.model.Paragraph -import works.iterative.ui.model.FormItem -import org.scalajs.dom.html +import java.time.LocalDate object Inputs: - def textArea( - updates: Observer[Option[Paragraph]] - ): HtmlComponent[html.TextArea, FormItem[Paragraph]] = - (i: FormItem[Paragraph]) => - ta( - idAttr := i.id, - name := i.id, - rows := 5, - cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", - value(i.value.toString), - onInput.mapToValue.setAsValue --> updates.contramap((v: String) => - Option(v).map(_.trim).filter(_.nonEmpty).map(Paragraph(_)) - ) - ) + private def inp[V]( + prop: Property[V], + updates: Observer[Validated[V]], + inputType: String, + mods: Option[Modifier[Input]] = None + )(using codec: FormCodec[V]): Input = + input( + idAttr := prop.id, + name := prop.name, + tpe := inputType, + cls := "block max-w-lg w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue.map(v => codec.toValue(v)) --> updates + ) + + class PlainInput[V](using FormCodec[V]) extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): Input = inp(prop, updates, "text") + + class OptionDateInput extends FormInput[Option[LocalDate]]: + override def render( + prop: Property[Option[LocalDate]], + updates: Observer[Validated[Option[LocalDate]]] + ): Input = + inp(prop, updates, "date", Some(autoComplete("date"))) diff --git a/ui/components/src/ui/components/tailwind/form/InvalidValue.scala b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala new file mode 100644 index 0000000..2504cdd --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/InvalidValue.scala @@ -0,0 +1,6 @@ +package works.iterative +package ui.components.tailwind.form + +import core.MessageId + +case class InvalidValue(message: MessageId) diff --git a/ui/components/src/ui/components/tailwind/form/Property.scala b/ui/components/src/ui/components/tailwind/form/Property.scala new file mode 100644 index 0000000..2ae210d --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/Property.scala @@ -0,0 +1,27 @@ +package works.iterative +package ui.components.tailwind.form + +// Property is a named value. +trait Property[V]: + def id: String + // Property identification + def name: String + // Value + def value: Option[V] + +trait PropertyDescription: + // Human label + def label: String + // Larger description + def description: Option[String] + +trait DescribedProperty[V] extends Property[V] with PropertyDescription + +case class FormProperty[V]( + id: String, + name: String, + label: String, + description: Option[String], + value: Option[V] +) extends Property[V] + with PropertyDescription diff --git a/ui/components/src/ui/components/tailwind/form/TextArea.scala b/ui/components/src/ui/components/tailwind/form/TextArea.scala new file mode 100644 index 0000000..8ed7541 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/TextArea.scala @@ -0,0 +1,28 @@ +package works.iterative +package ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom.html + +class TextArea[V]( + initialRows: Int = 5 +)(using codec: FormCodec[V]) + extends FormInput[V]: + override def render( + prop: Property[V], + updates: Observer[Validated[V]] + ): ReactiveHtmlElement[html.TextArea] = + def numberOfLines(s: String) = s.count(_ == '\n') + val currentValue = prop.value.map(codec.toForm) + val changeBus = EventBus[String]() + val rowNo = Var(currentValue.map(numberOfLines).getOrElse(initialRows)) + textArea( + changeBus.events.map(numberOfLines) --> rowNo, + changeBus.events.map(codec.toValue) --> updates, + name := prop.name, + rows <-- rowNo.signal.map(_ + 2), + cls := "max-w-lg shadow-sm block w-full focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border border-gray-300 rounded-md", + prop.value.map(v => value(codec.toForm(v))), + onInput.mapToValue.setAsValue --> changeBus.writer + ) diff --git a/ui/components/src/ui/components/tailwind/form/package.scala b/ui/components/src/ui/components/tailwind/form/package.scala new file mode 100644 index 0000000..764f650 --- /dev/null +++ b/ui/components/src/ui/components/tailwind/form/package.scala @@ -0,0 +1,7 @@ +package works.iterative +package ui.components.tailwind + +import zio.prelude.Validation + +package object form: + type Validated[V] = Validation[InvalidValue, V] diff --git a/ui/model/src/Form.scala b/ui/model/src/Form.scala index d9a978f..9ad7f99 100644 --- a/ui/model/src/Form.scala +++ b/ui/model/src/Form.scala @@ -1,16 +1,18 @@ package works.iterative package ui.model +import core.* + case class FormItem[Value]( id: String, - label: OneLine, - description: Option[Paragraph], + label: PlainOneLine, + description: Option[PlainMultiLine], value: Value ) case class FormSection( - header: OneLine, - description: Option[Paragraph], + header: PlainOneLine, + description: Option[PlainMultiLine], items: List[FormItem[_]] ) diff --git a/ui/model/src/ItemList.scala b/ui/model/src/ItemList.scala index 3aa7815..76e9dac 100644 --- a/ui/model/src/ItemList.scala +++ b/ui/model/src/ItemList.scala @@ -1,21 +1,24 @@ -package works.iterative.ui.model +package works.iterative +package ui.model + +import core.* case class ItemList( items: List[ListSection] ) case class ItemProp( - text: OneLine, + text: PlainOneLine, icon: Option[Icon] = None ) case class ListSection( - title: OneLine, + title: PlainOneLine, items: List[ListItem] ) case class ListItem( - title: OneLine, + title: PlainOneLine, href: String, label: Option[Label] = None, leftProps: List[ItemProp] = Nil, diff --git a/ui/model/src/Text.scala b/ui/model/src/Text.scala deleted file mode 100644 index 1ac70f0..0000000 --- a/ui/model/src/Text.scala +++ /dev/null @@ -1,32 +0,0 @@ -package works.iterative -package ui.model - -type Text = OneLine | Paragraph | List[Paragraph] - -// TODO: rename to MultiLine -opaque type Paragraph = String - -object Paragraph: - def apply(text: String): Paragraph = text - - given Conversion[String, Paragraph] with - def apply(text: String): Paragraph = text - - given Conversion[Paragraph, String] with - def apply(text: Paragraph): String = text - - extension (p: Paragraph) def toString: String = p - -opaque type OneLine = String - -object OneLine: - // TODO: check that the string actually is one line - def apply(text: String): OneLine = text - - given Conversion[String, OneLine] with - def apply(text: String): OneLine = text - - given Conversion[OneLine, String] with - def apply(text: OneLine): String = text - - extension (p: OneLine) def toString: String = p