diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..66506e9 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..66506e9 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..66506e9 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..66506e9 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..66506e9 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..b53f3f2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..66506e9 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..b53f3f2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..129abe4 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + case class ViewModel(text: String, color: Color) + def render($m: Signal[ViewModel]): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls <-- $m.map(t => colorClass(t.color)), + child.text <-- $m.map(_.text) + ) diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7fb2a3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,90 @@ +# use glob syntax. +syntax: glob +*.ser +*.class +*~ +*.bak +*.off +*.old +.DS_Store +/.direnv/ + +# eclipse conf file +.settings +.classpath +.project +.manager + +# building +target +null +tmp* +dist +test-output + +# other scm +.svn +.CVS +.hg* + +# switch to regexp syntax. +# syntax: regexp +# ^\.pc/ + +# IntelliJ +*.iws +*.ids +.idea +#.idea/workspace.xml +#.idea/tasks.xml +#.idea/datasources.xml +#.idea/libraries +#.idea/dictionaries + +# vim files +Session.vim +tags +.tags + +# Python compiled files +*.pyc + +# Vagrant files +.vagrant + +#Cache files +.cache + +#scripts +bin + +#is modules +node_modules +ext-lib + +#netbeans project +nbproject + +.idea_modules +logs + +# ensime project files +.ensime +.ensime_cache/ + +#local ensime config +ensime.sbt + +#visual code +.vscode + +# bloop +.bloop/ +.bsp/ +.metals/ +metals.sbt +out/ + +# semanticdb +*.semanticdb +/.sbt-hydra-history diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..52fc2f1 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,8 @@ +version = "3.0.0" + +// fileOverride { +// "glob:**/services/{documents,ares}/src/{main,test}/scala/**" { +// runner.dialect = scala3 +// } +// } +runner.dialect = scala3 diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala new file mode 100644 index 0000000..e7e6627 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/CommandHandlerException.scala @@ -0,0 +1,19 @@ +package works.iterative.akka + +// Base class for all command-processing related exceptions from handlers +sealed abstract class CommandHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandNotAvailable[C, S](cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd není dostupný ve stavu $state" + ) + +// TODO: use a typeclass like "Show" to create the error message +case class CommandRejected[C, S](reason: String, cmd: C, state: S) + extends CommandHandlerException( + s"Příkaz $cmd byl ve stavu $state odmítnut s odůvodněním $reason" + ) diff --git a/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala new file mode 100644 index 0000000..72a5bf9 --- /dev/null +++ b/akka-persistence/src/main/scala/fiftyforms/akka/EventHandlerException.scala @@ -0,0 +1,11 @@ +package works.iterative.akka + +sealed abstract class EventHandlerException( + msg: String, + cause: Option[Throwable] = None +) extends Exception(msg, cause.orNull) + +case class UnhandledEvent[Event, State](event: Event, state: State) + extends EventHandlerException( + s"Událost $event nastala ve stavu $state bez možnosti zpracování" + ) diff --git a/bom.sc b/bom.sc new file mode 100644 index 0000000..e9266b8 --- /dev/null +++ b/bom.sc @@ -0,0 +1,227 @@ +import mill._, scalalib._ + +object IWMaterials { + + object Versions { + val akka = "2.6.16" + val akkaHttp = "10.2.4" + val cats = "2.7.0" + val elastic4s = "7.12.2" + val http4s = "0.23.10" + val http4sPac4J = "4.0.0" + val laminar = "0.14.2" + val laminext = laminar + val logbackClassic = "1.2.10" + val pac4j = "5.2.0" + val play = "2.8.8" + val playJson = "2.9.2" + val scalaTest = "3.2.9" + val slick = "3.3.3" + val sttpClient = "3.5.0" + val tapir = "0.20.1" + val urlDsl = "0.4.0" + val waypoint = "0.5.0" + val zio = "2.0.0-RC2" + val zioConfig = "3.0.0-RC2" + val zioInteropCats = "3.3.0-RC2" + val zioJson = "0.3.0-RC3" + val zioLogging = "2.0.0-RC5" + val zioPrelude = "1.0.0-RC10" + val zioZMX = "0.0.11" + } + + object Deps extends AkkaLibs with SlickLibs { + import IWMaterials.{Versions => V} + + val zioOrg = "dev.zio" + + def zioLib(name: String, version: String): Dep = + ivy"$zioOrg::zio-$name::$version" + + lazy val zio: Dep = ivy"$zioOrg::zio:${V.zio}" + + lazy val zioTest: Dep = zioLib("test", V.zio) + lazy val zioTestSbt: Dep = zioLib("test", V.zio) + + lazy val zioConfig: Dep = zioLib("config", V.zioConfig) + lazy val zioConfigTypesafe: Dep = + zioLib("config-typesafe", V.zioConfig) + lazy val zioConfigMagnolia: Dep = + zioLib("config-magnolia", V.zioConfig) + + lazy val zioJson: Dep = zioLib("json", V.zioJson) + lazy val zioLogging: Dep = zioLib("logging", V.zioLogging) + lazy val zioLoggingSlf4j: Dep = + zioLib("logging-slf4j", V.zioLogging) + lazy val zioPrelude: Dep = zioLib("prelude", V.zioPrelude) + lazy val zioStreams: Dep = zioLib("streams", V.zio) + lazy val zioZMX: Dep = zioLib("zmx", V.zioZMX) + lazy val zioInteropCats: Dep = + zioLib("interop-cats", V.zioInteropCats) + + /* What is the equivalent? ZIOModule with prepared test config? + def useZIO(testConf: Configuration*): Agg[Dep] = Agg( + zio, + zioTest, + zioTestSbt, + testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework") + ) + */ + + /* + def useZIOAll(testConf: Configuration*): Seq[Def.Setting[_]] = + useZIO(testConf: _*) ++ Seq( + zioStreams, + zioConfig, + zioConfigTypesafe, + zioConfigMagnolia, + zioJson, + zioZMX, + zioLogging, + zioPrelude + ) + */ + + private val tapirOrg = "com.softwaremill.sttp.tapir" + def tapirLib(name: String): Dep = + ivy"${tapirOrg}::tapir-$name::${V.tapir}" + + lazy val tapirCore: Dep = tapirLib("core") + lazy val tapirZIO: Dep = tapirLib("zio") + lazy val tapirZIOJson: Dep = tapirLib("json-zio") + lazy val tapirSttpClient: Dep = tapirLib("sttp-client") + lazy val tapirCats: Dep = tapirLib("cats") + lazy val tapirZIOHttp4sServer: Dep = tapirLib("zio-http4s-server") + + private val sttpClientOrg = "com.softwaremill.sttp.client3" + def sttpClientLib(name: String): Dep = + ivy"${sttpClientOrg}::${name}:${V.sttpClient}" + + lazy val sttpClientCore: Dep = sttpClientLib("core") + + lazy val http4sBlazeServer: Dep = + ivy"org.http4s::http4s-blaze-server:${V.http4s}" + + lazy val http4sPac4J: Dep = + ivy"org.pac4j::http4s-pac4j:${V.http4sPac4J}" + lazy val pac4jOIDC: Dep = + ivy"org.pac4j:pac4j-oidc:${V.pac4j}" + + lazy val scalaTest: Dep = + ivy"org.scalatest::scalatest:${V.scalaTest}" + lazy val scalaTestPlusScalacheck: Dep = + ivy"org.scalatestplus::scalacheck-1-15:3.2.9.0" + lazy val playScalaTest: Dep = + ivy"org.scalatestplus.play::scalatestplus-play:5.1.0" + + private val playOrg = "com.typesafe.play" + lazy val playMailer: Dep = ivy"${playOrg}::play-mailer:8.0.1" + lazy val playServer: Dep = ivy"${playOrg}::play-server:${V.play}" + lazy val playAkkaServer: Dep = + ivy"${playOrg}::play-akka-http-server:${V.play}" + lazy val play: Dep = ivy"${playOrg}::play:${V.play}" + lazy val playAhcWs: Dep = ivy"${playOrg}::play-ahc-ws:${V.play}" + lazy val playJson: Dep = ivy"${playOrg}::play-json:${V.playJson}" + + private val elastic4sOrg = "com.sksamuel.elastic4s" + lazy val useElastic4S: Agg[Dep] = Agg( + ivy"${elastic4sOrg}::elastic4s-core:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-client-akka:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-http-streams:${V.elastic4s}", + ivy"${elastic4sOrg}::elastic4s-json-play:${V.elastic4s}" + ) + + lazy val laminar: Dep = ivy"com.raquo::laminar::${V.laminar}" + + private def laminext(name: String): Dep = + ivy"io.laminext::$name::${V.laminar}" + + lazy val laminextCore: Dep = laminext("core") + lazy val laminextTailwind: Dep = laminext("tailwind") + lazy val laminextFetch: Dep = laminext("fetch") + lazy val laminextValidationCore: Dep = laminext("validation-core") + lazy val laminextUI: Dep = laminext("ui") + + lazy val waypoint: Dep = + ivy"com.raquo::waypoint::${V.waypoint}" + + lazy val urlDsl: Dep = + ivy"be.doeraene::url-dsl::${V.urlDsl}" + + lazy val scalaJavaTime: Dep = + ivy"io.github.cquiroz::scala-java-time::2.3.0" + + lazy val scalaJavaLocales: Dep = + ivy"io.github.cquiroz::scala-java-locales::1.2.1" + + lazy val logbackClassic: Dep = + ivy"ch.qos.logback:logback-classic:${V.logbackClassic}" + } + + trait AkkaLibs { + + self: SlickLibs => + + object akka { + val V = Versions.akka + val tOrg = "com.typesafe.akka" + val lOrg = "com.lightbend.akka" + + def akkaMod(name: String): Dep = + ivy"$tOrg::akka-$name:$V" + + lazy val actor: Dep = akkaMod("actor") + lazy val actorTyped: Dep = akkaMod("actor-typed") + lazy val stream: Dep = akkaMod("stream") + lazy val persistence: Dep = akkaMod("persistence-typed") + lazy val persistenceQuery: Dep = akkaMod("persistence-query") + lazy val persistenceJdbc: Dep = + ivy"$lOrg::akka-persistence-jdbc:5.0.4" + val persistenceTestKit: Dep = ivy"$tOrg::akka-persistence-testkit:$V" + + object http { + val V = Versions.akkaHttp + + lazy val http: Dep = ivy"$tOrg::akka-http:$V" + lazy val sprayJson: Dep = ivy"$tOrg::akka-http-spray-json:$V" + + } + + object projection { + val V = "1.2.2" + + lazy val core: Dep = + ivy"$lOrg::akka-projection-core:$V" + lazy val eventsourced: Dep = + ivy"$lOrg::akka-projection-eventsourced:$V" + lazy val slick: Dep = + ivy"$lOrg::akka-projection-slick:$V" + lazy val jdbc: Dep = + ivy"$lOrg::akka-projection-jdbc:$V" + } + + object profiles { + // TODO: deal with cross-version for Scala3 (for3Use2_13) + lazy val eventsourcedJdbcProjection: Agg[Dep] = Agg( + persistenceQuery, + projection.core, + projection.eventsourced, + projection.slick, + persistenceJdbc + ) ++ slick.default + } + } + } + + trait SlickLibs { + object slick { + val V = IWMaterials.Versions.slick + val org = "com.typesafe.slick" + + lazy val slick: Dep = ivy"$org::slick:$V" + lazy val hikaricp: Dep = ivy"$org::slick-hikaricp:$V" + lazy val default: Agg[Dep] = Agg(slick, hikaricp) + } + } + +} diff --git a/build.sc b/build.sc new file mode 100644 index 0000000..fbbc3b8 --- /dev/null +++ b/build.sc @@ -0,0 +1,144 @@ +import mill._, scalalib._, scalajslib._ + +import $file.bom + +object support { + val Deps = bom.IWMaterials.Deps + val Versions = bom.IWMaterials.Versions + + trait CommonModule extends ScalaModule { + def scalaVersion = "3.1.1" + def scalacOptions = Seq( + "-encoding", + "utf8", + "-deprecation", + "-explain-types", + "-explain", + "-feature", + "-language:experimental.macros", + "-language:higherKinds", + "-language:implicitConversions", + "-unchecked", + "-Xfatal-warnings", + "-Ykind-projector" + ) + } + + trait CommonJSModule extends CommonModule with ScalaJSModule { + def scalaJSVersion = "1.8.0" + } + + trait CrossPlatformModule extends Module { outer => + def ivyDeps: T[Agg[Dep]] = Agg[Dep]() + def jsDeps: T[Agg[Dep]] = Agg[Dep]() + def jvmDeps: T[Agg[Dep]] = Agg[Dep]() + def moduleDeps: Seq[Module] = Seq[Module]() + + trait PlatformModule extends CommonModule { + def platform: String + override def millSourcePath = outer.millSourcePath + override def ivyDeps = outer.ivyDeps() ++ (platform match { + case "js" => jsDeps() + case _ => jvmDeps() + }) + override def moduleDeps = outer.moduleDeps.collect { + case m: CrossPlatformModule => + platform match { + case "js" => m.js + case _ => m.jvm + } + case m: JavaModule => m + } + } + + trait JsModule extends PlatformModule with CommonJSModule { + def platform = "js" + } + + trait JvmModule extends PlatformModule { + def platform = "jvm" + } + + val js: JsModule + val jvm: JvmModule + } + + trait PureCrossModule extends CrossPlatformModule { + override object js extends JsModule + override object jvm extends JvmModule + } + + trait PureCrossSbtModule extends CrossPlatformModule { + override object js extends JsModule with SbtModule + override object jvm extends JvmModule with SbtModule + } + + trait FullCrossSbtModule extends CrossPlatformModule { + trait FullSources extends JavaModule { self: PlatformModule => + override def sources = T.sources( + millSourcePath / platform / "src" / "main" / "scala", + millSourcePath / "shared" / "src" / "main" / "scala" + ) + + override def resources = T.sources( + millSourcePath / platform / "src" / "main" / "resources", + millSourcePath / "shared" / "src" / "main" / "resources" + ) + } + + override object js extends JsModule with FullSources + override object jvm extends JvmModule with FullSources + } + +} + +import support._ + +object mongo extends CommonModule { + def ivyDeps = Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + ivy"org.mongodb.scala::mongo-scala-driver:4.2.3".withDottyCompat( + scalaVersion() + ) + ) +} + +object tapir extends FullCrossSbtModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson, Deps.zioJson) + def jvmDeps = Agg(Deps.tapirZIO, Deps.tapirZIOHttp4sServer) + def jsDeps = Agg(Deps.tapirSttpClient) +} + +object akkaPersistence extends CommonModule { + def millSourcePath = build.millSourcePath / "akka-persistence" + def ivyDeps = (Agg( + Deps.akka.persistenceQuery, + Deps.akka.persistenceJdbc, + Deps.akka.projection.core, + Deps.akka.projection.eventsourced, + Deps.akka.projection.slick + ) ++ Deps.slick.default).map(_.withDottyCompat(scalaVersion())) ++ Agg( + Deps.zio, + Deps.zioJson, + Deps.zioConfig, + Deps.akka.persistence.withDottyCompat(scalaVersion()), + ivy"com.typesafe.akka::akka-cluster-sharding-typed:${Deps.akka.V}" + .withDottyCompat(scalaVersion()) + ) +} + +object ui extends CommonJSModule { + def ivyDeps = Agg( + Deps.zio, + Deps.laminar, + Deps.zioJson, + Deps.waypoint, + Deps.urlDsl, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) +} diff --git a/domain.sc b/domain.sc new file mode 100644 index 0000000..b09c56b --- /dev/null +++ b/domain.sc @@ -0,0 +1,78 @@ +import mill._, scalalib._ + +import $file.{build => ff}, ff.support._ + +trait DomainModule extends Module { + // General extra model deps + def modelModules: Seq[Module] = Seq.empty + // General extra codecs deps + def codecsModules: Seq[Module] = Seq.empty + // General extra endpoints deps + def endpointsModules: Seq[Module] = Seq.empty + + // Implementation deps for repo + def repoModules: Seq[JavaModule] = Seq.empty + + object shared extends Module { + object model extends PureCrossModule { + def moduleDeps = modelModules + def ivyDeps = Agg(Deps.zioPrelude) + } + object codecs extends PureCrossModule { + def moduleDeps = Seq(model) ++ codecsModules + def ivyDeps = Agg(Deps.zioJson) + } + } + + trait CommonProjects extends Module { + object model extends PureCrossModule { + def ivyDeps = Agg(Deps.zioPrelude) + def moduleDeps = Seq(shared.model) + } + object codecs extends PureCrossModule { + def moduleDeps = + Seq(model, shared.model, shared.codecs, ff.tapir) + } + object endpoints extends PureCrossModule { + def ivyDeps = Agg(Deps.tapirCore, Deps.tapirZIOJson) + def moduleDeps = Seq(model, codecs) ++ endpointsModules + } + object client extends CommonJSModule { + def moduleDeps = Seq(endpoints.js) + } + object components extends CommonJSModule { + def ivyDeps = Agg( + Deps.laminar, + Deps.laminextCore, + Deps.laminextUI, + Deps.laminextTailwind, + Deps.laminextValidationCore + ) + def moduleDeps = Seq(model.js, codecs.js, client, ff.ui) + } + } + + object query extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(repo, query.endpoints.jvm) + } + object repo extends CommonModule { + def ivyDeps = Agg(Deps.zio) + def moduleDeps = Seq(model.jvm, codecs.jvm) ++ repoModules + } + object projection extends CommonModule { + def moduleDeps = Seq(repo, ff.akkaPersistence) + } + } + + object command extends CommonProjects { + object api extends CommonModule { + def ivyDeps = Agg(Deps.zio, Deps.tapirZIOHttp4sServer) + def moduleDeps = Seq(entity, command.endpoints.jvm) + } + object entity extends CommonModule { + def moduleDeps = Seq(model.jvm, codecs.jvm, ff.akkaPersistence) + } + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ba4170a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "flake-utils": { + "locked": { + "lastModified": 1644229661, + "narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1646939531, + "narHash": "sha256-bxOjVqcsccCNm+jSmEh/bm0tqfE3SdjwS+p+FZja3ho=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "fcd48a5a0693f016a5c370460d0c2a8243b882dc", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d729808 --- /dev/null +++ b/flake.nix @@ -0,0 +1,22 @@ +{ + description = "MDR Personální databáze"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; + }; + in { devShell = import ./shell.nix { inherit pkgs; }; }); +} diff --git a/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala new file mode 100644 index 0000000..0fa3493 --- /dev/null +++ b/mongo/src/main/scala/fiftyforms/mongo/MongoJsonRepository.scala @@ -0,0 +1,53 @@ +package works.iterative.mongo + +import zio.* +import zio.json.* +import zio.config.* +import org.mongodb.scala.* +import org.mongodb.scala.model.Filters.* +import org.bson.json.JsonObject +import org.mongodb.scala.model.ReplaceOptions +import org.mongodb.scala.bson.conversions.Bson + +case class MongoConfig(uri: String) + +object MongoConfig: + val configDesc = + import ConfigDescriptor.* + nested("MONGO")(string("URI").default("mongodb://localhost:27017")) + .to[MongoConfig] + val fromEnv = ZConfig.fromSystemEnv(configDesc) + +extension (m: MongoClient.type) + def layer: RLayer[MongoConfig, MongoClient] = + ZIO + .serviceWithZIO[MongoConfig](c => Task.attempt(MongoClient(c.uri))) + .toLayer + +class MongoJsonRepository[Elem, Key, Criteria]( + collection: MongoCollection[JsonObject], + toFilter: Criteria => Bson, + idFilter: Elem => (String, Key) +)(using JsonCodec[Elem]) { + def matching(criteria: Criteria): Task[Seq[Elem]] = + val filter = toFilter(criteria) + val query = collection.find(filter) + + for + result <- ZIO.fromFuture(_ => query.toFuture) + proof <- ZIO.collect(result)(j => + ZIO.fromOption(j.getJson.fromJson[Elem].toOption) + ) + yield proof + + def put(elem: Elem): Task[Unit] = + Task.async(cb => + collection + .replaceOne( + equal.tupled(idFilter(elem)), + JsonObject(elem.toJson), + ReplaceOptions().upsert(true) + ) + .subscribe(_ => cb(Task.unit), t => cb(Task.fail(t))) + ) +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..2582231 --- /dev/null +++ b/shell.nix @@ -0,0 +1,13 @@ +{ pkgs ? import { + overlays = [ + (final: prev: rec { + jre = prev.adoptopenjdk-hotspot-bin-11; + jdk = jre; + }) + ]; +} }: + +with pkgs; +mkShell { + buildInputs = [ jre ammonite coursier bloop mill sbt scalafmt nodejs-16_x ]; +} diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala new file mode 100644 index 0000000..68d7196 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/BaseUri.scala @@ -0,0 +1,12 @@ +package works.iterative.tapir + +import sttp.model.Uri + +opaque type BaseUri = Option[Uri] + +object BaseUri: + + def apply(optU: Option[Uri]): BaseUri = optU + def apply(u: Uri): BaseUri = Some(u) + + extension (v: BaseUri) def toUri: Option[Uri] = v diff --git a/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..653bc51 --- /dev/null +++ b/tapir/js/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,22 @@ +package works.iterative.tapir + +import sttp.tapir.client.sttp.SttpClientInterpreter +import sttp.tapir.PublicEndpoint +import sttp.tapir.client.sttp.WebSocketToPipe +import scala.concurrent.Future +import sttp.client3.SttpBackend +import sttp.capabilities.WebSockets + +trait CustomTapirPlatformSpecific extends SttpClientInterpreter: + self: CustomTapir => + + type Backend = SttpBackend[Future, WebSockets] + + def makeClient[I, E, O]( + endpoint: PublicEndpoint[I, E, O, Any] + )(using + baseUri: BaseUri, + backend: Backend, + wsToPipe: WebSocketToPipe[Any] + ): I => Future[O] = + toClientThrowErrors(endpoint, baseUri.toUri, backend) diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala new file mode 100644 index 0000000..2545fbd --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/CustomTapirPlatformSpecific.scala @@ -0,0 +1,5 @@ +package works.iterative.tapir + +import sttp.tapir.ztapir.ZTapir + +trait CustomTapirPlatformSpecific extends ZTapir diff --git a/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala new file mode 100644 index 0000000..55e4ca1 --- /dev/null +++ b/tapir/jvm/src/main/scala/fiftyforms/tapir/Http4sCustomTapir.scala @@ -0,0 +1,7 @@ +package works.iterative.tapir + +import sttp.tapir.server.http4s.ztapir.ZHttp4sServerInterpreter + +trait Http4sCustomTapir[Env] + extends CustomTapir + with ZHttp4sServerInterpreter[Env] diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala new file mode 100644 index 0000000..dd52e9b --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/CustomTapir.scala @@ -0,0 +1,14 @@ +package works.iterative.tapir + +import sttp.tapir.Tapir +import sttp.tapir.json.zio.TapirJsonZio +import sttp.tapir.TapirAliases + +trait CustomTapir + extends Tapir + with TapirJsonZio + with TapirAliases + with CustomTapirPlatformSpecific: + given Schema[ServerError] = Schema.derived + +object CustomTapir extends CustomTapir diff --git a/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala new file mode 100644 index 0000000..715591d --- /dev/null +++ b/tapir/shared/src/main/scala/fiftyforms/tapir/ServerError.scala @@ -0,0 +1,13 @@ +package works.iterative.tapir + +import zio.json.* + +sealed trait ServerError +case class InternalServerError(msg: String) extends ServerError +object InternalServerError: + def fromThrowable(t: Throwable): ServerError = InternalServerError( + t.getMessage + ) + +object ServerError: + given JsonCodec[ServerError] = DeriveJsonCodec.gen diff --git a/ui/src/main/scala/fiftyforms/services/files/File.scala b/ui/src/main/scala/fiftyforms/services/files/File.scala new file mode 100644 index 0000000..09f6c07 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/File.scala @@ -0,0 +1,3 @@ +package works.iterative.services.files + +case class File(url: String, name: String) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala new file mode 100644 index 0000000..46633a5 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/File.scala @@ -0,0 +1,25 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Renderable + +given Renderable[File] with + extension (m: File) + def toHtml: 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() + .amend(svg.cls := "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/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala new file mode 100644 index 0000000..ac59db8 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileList.scala @@ -0,0 +1,13 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import works.iterative.ui.components.tailwind.Icons +import io.laminext.syntax.core.{*, given} + +def FileList(files: List[File]): HtmlElement = + ul( + role("list"), + cls("border border-gray-200 rounded-md divide-y divide-gray-200"), + files.map(_.toHtml) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala new file mode 100644 index 0000000..2e8f690 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FilePicker.scala @@ -0,0 +1,81 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.{*, given} + +object FilePicker: + sealed trait Event + sealed trait DoneEvent extends Event + case class SelectionUpdated(files: Set[File]) extends DoneEvent + case object SelectionCancelled extends DoneEvent + case object AvailableFilesRequested extends Event + + def apply( + currentFiles: Signal[List[File]], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val (updatesStream, updatesObserver) = EventStream.withObserver[Event] + val selectorOpen = Var[Boolean](false) + + // This sequence tricks browser into displaying modal content centered + // Inspired by modal in headless ui playground + // https://github.com/tailwindlabs/headlessui/blob/fdd26297953080d5ec905dda0bf5ec9607897d86/packages/playground-react/pages/transitions/component-examples/modal.tsx#L78-L79 + inline def browserCenteringModalTrick: Modifier[HtmlElement] = + Seq[Modifier[HtmlElement]]( + span(cls("hidden sm:inline-block sm:h-screen sm:align-middle")), + "​" // Zero width space + ) + + inline def overlay: Modifier[HtmlElement] = + // Page overlay + /* TODO: transition + enter="ease-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in duration-200" + leaveFrom="opacity-100" + leaveTo="opacity-0" + */ + div( + div( + onClick.mapTo(false) --> selectorOpen.writer, + cls("fixed inset-0 transition-opacity"), + div(cls("absolute inset-0 bg-gray-500 opacity-75")) + ) + ) + + inline def modalSelector: HtmlElement = + div( + cls("fixed inset-0 z-10 overflow-y-auto"), + div( + cls("text-center sm:block sm:p-0"), + overlay, + browserCenteringModalTrick, + child <-- currentFiles.map( + FileSelector(_, availableFilesStream)(updatesObserver) + ) + ) + ) + + div( + updatesStream --> selectionUpdates, + updatesStream.collect { case _: DoneEvent => + false + } --> selectorOpen.writer, + child.maybe <-- selectorOpen.signal.map(isOpen => + if isOpen then Some(modalSelector) else None + ), + div( + cls("flex flex-col space-y-5"), + child.maybe <-- currentFiles.map(files => + if files.isEmpty then None else Some(FileList(files)) + ), + button( + tpe := "button", + cls := "bg-white py-2 px-3 border border-gray-300 rounded-md shadow-sm text-sm leading-4 font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + "Zvolit soubory", + onClick.mapTo(true) --> selectorOpen.writer + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala new file mode 100644 index 0000000..b43e07a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileSelector.scala @@ -0,0 +1,74 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons +import works.iterative.ui.components.tailwind.Loading +import io.laminext.syntax.core.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object FileSelector: + import FilePicker._ + + def apply( + initialFiles: List[File], + availableFilesStream: EventStream[List[File]] + )(selectionUpdates: Observer[Event]): HtmlElement = + val selectedFiles = Var[Set[File]](initialFiles.to(Set)) + val availableFiles = availableFilesStream.startWithNone + // Request the files to display + selectionUpdates.onNext(AvailableFilesRequested) + div( + cls( + "inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle" + ), + role("dialog"), + customHtmlAttr("aria.modal", BooleanAsTrueFalseStringCodec)(true), + aria.labelledBy("modal-headline"), + div( + cls("bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"), + div( + cls("sm:flex sm:items-start"), + div( + cls("mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"), + h3( + cls("text-lg font-medium leading-6 text-gray-900"), + idAttr("modal-headline"), + "Výběr souborů" + ) + ) + ), + child <-- availableFiles + .split(_ => ())((_, _, af) => FileTable(af, selectedFiles)) + .map(_.getOrElse(Loading)) + ), + div( + cls("bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6"), + span( + cls("flex w-full rounded-md shadow-sm sm:ml-3 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-green inline-flex w-full justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-base font-medium leading-6 text-white shadow-sm transition duration-150 ease-in-out hover:bg-indigo-500 focus:border-indigo-700 focus:outline-none sm:text-sm sm:leading-5" + ), + "Potvrdit", + composeEvents(onClick)( + _.sample(selectedFiles) + .map(SelectionUpdated(_)) + ) --> selectionUpdates + ) + ), + span( + cls("mt-3 flex w-full rounded-md shadow-sm sm:mt-0 sm:w-auto"), + button( + typ("button"), + cls( + "focus:shadow-outline-blue inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium leading-6 text-gray-700 shadow-sm transition duration-150 ease-in-out hover:text-gray-500 focus:border-blue-300 focus:outline-none sm:text-sm sm:leading-5" + ), + "Zrušit", + onClick.mapTo(SelectionCancelled) --> selectionUpdates + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala new file mode 100644 index 0000000..aa11b2b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/services/files/components/tailwind/FileTable.scala @@ -0,0 +1,91 @@ +package works.iterative.services.files +package components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import works.iterative.ui.components.tailwind.Icons + +def FileTable( + files: Signal[List[File]], + selectedFiles: Var[Set[File]] +): HtmlElement = + val scope = customHtmlAttr("scope", StringAsIsCodec) + + def headerRow: HtmlElement = + val col = scope("col") + val baseM: Modifier[HtmlElement] = Seq(cls("px-6 py-3"), col) + val textH = cls( + "text-left text-xs font-medium text-gray-500 uppercase tracking-wider" + ) + tr( + th(baseM, span(cls("sr-only"), "Vybrat")), + th(baseM, textH, "Název"), + th(baseM, cls("relative"), span(cls("sr-only"), "Otevřít")) + ) + + def tableRow( + f: File, + idx: Int, + selected: Boolean + )(toggleSelection: Observer[Unit]): HtmlElement = + val baseC = cls("px-6 py-4 whitespace-nowrap text-sm") + tr( + cls(if idx % 2 == 0 then "bg-gray-50" else "bg-white"), + td( + cls("font-medium cursor-pointer"), + onClick.mapTo(()) --> toggleSelection, + cls(if selected then "text-green-900" else "text-gray-200"), + Icons.outline.`check-circle`().amend(svg.cls := "mx-auto"), + span(cls("sr-only"), if selected then "Vybráno" else "Nevybráno") + ), + td( + baseC, + cls("font-medium text-gray-900"), + f.name, + onClick.mapTo(()) --> toggleSelection + ), + td( + baseC, + cls("text-right font-medium"), + a( + href(f.url), + target("_blank"), + cls( + "flex items-center space-x-4 text-indigo-600 hover:text-indigo-900" + ), + Icons.outline.`external-link`(), + "Otevřít" + ) + ) + ) + + div( + cls("flex flex-col"), + div(cls("overflow-x-auto sm:-mx-6 lg:-mx-8")), + div( + cls("py-2 align-middle inline-block min-w-full sm:px-6 lg:px-8"), + div( + cls("shadow overflow-hidden border-b border-gray-200 sm:rounded-lg"), + table( + cls("min-w-full divide-y divide-gray-200"), + thead( + cls("bg-gray-50"), + headerRow + ), + tbody( + children <-- files + .map(_.zipWithIndex) + .combineWithFn(selectedFiles)((f, sel) => + f.map((file, idx) => + val active = sel.contains(file) + tableRow(file, idx, active)( + selectedFiles.writer + .contramap(_ => if active then sel - file else sel + file) + ) + ) + ) + ) + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala new file mode 100644 index 0000000..0fc8796 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Avatar.scala @@ -0,0 +1,32 @@ +package works.iterative.ui.components.tailwind + +import CustomAttrs.ariaHidden +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +// TODO: render icon or picture based on img signal +class Avatar($avatarImg: Signal[Option[String]]): + inline def avatarPlaceholder(size: Int): HtmlElement = + div( + cls := s"rounded-full text-indigo-200 bg-indigo-500 h-${size} w-${size} flex items-center justify-center", + Icons.outline.user(size - 2) + ) + + inline def avatarImage(size: Int): Signal[HtmlElement] = + $avatarImg.split(_ => ())((_, _, $url) => + img( + cls := s"w-$size h-$size rounded-full", + src <-- $url, + alt := "" + ) + ).map(_.getOrElse(avatarPlaceholder(size))) + + inline def avatar(size: Int): HtmlElement = + div( + cls := "relative", + child <-- avatarImage(size), + span( + cls := "absolute inset-0 shadow-inner rounded-full", + ariaHidden := true + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala new file mode 100644 index 0000000..bcbed88 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Color.scala @@ -0,0 +1,77 @@ +package works.iterative.ui.components.tailwind + +enum ColorWeight(value: String): + inline def toCSS: String = value + // To be used for black and white until + // we support better mechanism via extension methods + case w__ extends ColorWeight("") + case w50 extends ColorWeight("50") + case w100 extends ColorWeight("100") + case w200 extends ColorWeight("200") + case w300 extends ColorWeight("300") + case w400 extends ColorWeight("400") + case w500 extends ColorWeight("500") + case w600 extends ColorWeight("600") + case w700 extends ColorWeight("700") + case w800 extends ColorWeight("800") + case w900 extends ColorWeight("900") + +enum Color(name: String): + import ColorWeight._ + + inline def toCSSNoColorWeight(prefix: String): String = + s"${prefix}-${name}" + inline def toCSSWithColorWeight( + prefix: String, + weight: ColorWeight + ): String = + s"${prefix}-${name}-${weight.toCSS}" + inline def toCSS(prefix: String)(weight: ColorWeight): String = + weight match { + case `w__` => toCSSNoColorWeight(prefix) + case _ => toCSSWithColorWeight(prefix, weight) + } + inline def bg: ColorWeight => String = toCSS("bg")(_) + inline def text: ColorWeight => String = toCSS("text")(_) + inline def decoration: ColorWeight => String = toCSS("decoration")(_) + inline def border: ColorWeight => String = toCSS("border")(_) + inline def outline: ColorWeight => String = toCSS("outline")(_) + inline def divide: ColorWeight => String = toCSS("divide")(_) + inline def ring: ColorWeight => String = toCSS("ring")(_) + inline def ringOffset: ColorWeight => String = toCSS("ring-offset")(_) + inline def shadow: ColorWeight => String = toCSS("shadow")(_) + inline def accent: ColorWeight => String = toCSS("accent")(_) + + // TODO: change the "stupid" methods to extension methods + // that will keep the invariants in comments lower + case current extends Color("current") + case inherit extends Color("inherit") + // Not present in for all methods + case transp extends Color("transparent") + // Seen in accent, not preset otherwise + case auto extends Color("auto") + // Black and white do not have weight + case black extends Color("black") + case white extends Color("white") + case slate extends Color("slate") + case gray extends Color("gray") + case zinc extends Color("zinc") + case neutral extends Color("neutral") + case stone extends Color("stone") + case red extends Color("red") + case orange extends Color("orange") + case amber extends Color("amber") + case yellow extends Color("yellow") + case lime extends Color("lime") + case green extends Color("green") + case emerald extends Color("emerald") + case teal extends Color("teal") + case cyan extends Color("cyan") + case sky extends Color("sky") + case blue extends Color("blue") + case indigo extends Color("indigo") + case violet extends Color("violet") + case purple extends Color("purple") + case fuchsia extends Color("fuchsia") + case pink extends Color("pink") + case rose extends Color("rose") diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala new file mode 100644 index 0000000..6fb453c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/CustomAttrs.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.generic.codecs.StringAsIsCodec +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec + +object CustomAttrs { + // Made a pull request to add aria-current to scala-dom-types, remove after + val ariaCurrent = customHtmlAttr("aria-current", StringAsIsCodec) + val ariaHidden = customHtmlAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + + val datetime = customHtmlAttr("datetime", StringAsIsCodec) + + object svg { + import com.raquo.laminar.api.L.svg.{*, given} + val ariaHidden = + customSvgAttr("aria-hidden", BooleanAsTrueFalseStringCodec) + } +} diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala new file mode 100644 index 0000000..6cda48b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Display.scala @@ -0,0 +1,28 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +object Display: + + enum Breakpoint: + case sm, md, lg, xl, `2xl` + + enum DisplayClass: + case block, `inline-block`, `inline`, flex, `inline-flex`, table, + `inline-table`, `table-caption` + + object ShowUpFrom: + inline def apply( + br: Breakpoint, + dc: DisplayClass = DisplayClass.block + ): HtmlElement = + div( + cls := "hidden", + cls := s"${br}:${dc}" + ) + + object HideUpTo: + inline def apply(br: Breakpoint): HtmlElement = + div( + cls := s"${br}:hidden" + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala new file mode 100644 index 0000000..4a8b065 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Icons.scala @@ -0,0 +1,270 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.domtypes.generic.codecs.BooleanAsTrueFalseStringCodec +import com.raquo.domtypes.generic.defs.attrs.AriaAttrs +import com.raquo.laminar.api.L.svg.{*, given} +import com.raquo.laminar.builders.SvgBuilders +import com.raquo.laminar.keys.ReactiveSvgAttr + +// TODO: fix sizes, colors, hover and stuff, normalize and amend on call site +object Icons: + object aria: + inline def hidden = CustomAttrs.svg.ariaHidden + + object outline: + val defaultSize: Int = 6 + + inline def bell(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" + ) + ) + + inline def `check-circle`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + ) + ) + + inline def `document-add`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" + ) + ) + + inline def `external-link`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" + ) + ) + + inline def menu(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M4 6h16M4 12h16M4 18h16" + ) + ) + + inline def `status-offline`(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414" + ) + ) + + inline def user(size: Int = defaultSize) = + svg( + cls := s"w-${size} h-${size}", + fill := "none", + stroke := "currentColor", + viewBox := "0 0 24 24", + xmlns := "http://www.w3.org/2000/svg", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" + ) + ) + + inline def x(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + fill := "none", + viewBox := "0 0 24 24", + stroke := "currentColor", + aria.hidden := true, + path( + strokeLineCap := "round", + strokeLineJoin := "round", + strokeWidth := "2", + d := "M6 18L18 6M6 6l12 12" + ) + ) + + end outline + + object solid: + val defaultSize: Int = 5 + + inline def users(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" + ) + ) + + inline def `location-marker`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z", + clipRule := "evenodd" + ) + ) + + inline def calendar(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z", + clipRule := "evenodd" + ) + ) + + inline def `chevron-right`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z", + clipRule := "evenodd" + ) + ) + + inline def search(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z", + clipRule := "evenodd" + ) + ) + + inline def filter(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M3 3a1 1 0 011-1h12a1 1 0 011 1v3a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V3z", + clipRule := "evenodd" + ) + ) + + inline def `arrow-narrow-left`(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M7.707 14.707a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l2.293 2.293a1 1 0 010 1.414z", + clipRule := "evenodd" + ) + ) + + inline def home(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size}", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + d := "M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" + ) + ) + + inline def paperclip(size: Int = defaultSize) = + svg( + cls := s"h-${size} w-${size} text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + aria.hidden := true, + path( + fillRule := "evenodd", + d := "M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z", + clipRule := "evenodd" + ) + ) + + end solid +end Icons diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala new file mode 100644 index 0000000..88f0b9a --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/LinkSupport.scala @@ -0,0 +1,12 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.domtypes.jsdom.defs.events.TypedTargetMouseEvent + +object LinkSupport: + + extension [El <: org.scalajs.dom.EventTarget]( + ep: EventProcessor[TypedTargetMouseEvent[El], TypedTargetMouseEvent[El]] + ) + def noKeyMod = + ep.filter(ev => !(ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey)) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala new file mode 100644 index 0000000..5399933 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Loader.scala @@ -0,0 +1,13 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +// TODO: proper loader +def Loading = + div( + cls := "bg-gray-50 overflow-hidden rounded-lg", + div( + cls := "px-4 py-5 sm:p-6", + "Loading..." + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala new file mode 100644 index 0000000..a0fcc5b --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/Renderable.scala @@ -0,0 +1,6 @@ +package works.iterative.ui.components.tailwind + +import com.raquo.laminar.api.L.{*, given} + +trait Renderable[A]: + extension (a: A) def toHtml: HtmlElement diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala new file mode 100644 index 0000000..8c5b8a1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/ComboBox.scala @@ -0,0 +1,92 @@ +package works.iterative.ui.components.tailwind +package form + +import com.raquo.laminar.api.L.{*, given} +import io.laminext.syntax.core.* + +case class ComboBox( + id: String, + options: Signal[List[ComboBox.Option]], + valueUpdates: Observer[List[String]] +) + +object ComboBox: + + extension (m: ComboBox) + def toHtml: HtmlElement = + val isOpen = Var(false) + div( + cls := "relative mt-1", + input( + idAttr := m.id, + tpe := "text", + cls := "w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-12 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm", + role := "combobox", + aria.controls := "options", + aria.expanded := false, + onClick.mapTo(true) --> isOpen.writer + ), + button( + tpe := "button", + cls := "absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-none", { + import svg.* + svg( + cls := "h-5 w-5 text-gray-400", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z", + clipRule := "evenodd" + ) + ) + } + ), + ul( + cls <-- isOpen.signal.switch("", "hidden"), + cls := "absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm", + idAttr := "options", + role := "listbox", + children <-- m.options.map(_.map(_.toHtml)) + ) + ) + + case class Option(value: String, active: Boolean) + + object Option: + extension (m: Option) + def toHtml: HtmlElement = + li( + cls := "relative cursor-default select-none py-2 pl-8 pr-4", + cls := (if m.active then "text-white bg-indigo-600" + else "text-gray-900"), + idAttr := "option-0", + role := "option", + tabIndex := -1, + span( + cls := "block truncate", + m.value + ), + if m.active then + span( + cls := "absolute inset-y-0 left-0 flex items-center pl-1.5", + cls := (if m.active then "text-white" else "text-indigo-600"), { + import svg.* + svg( + cls := "h-5 w-5", + xmlns := "http://www.w3.org/2000/svg", + viewBox := "0 0 20 20", + fill := "currentColor", + CustomAttrs.svg.ariaHidden := true, + path( + fillRule := "evenodd", + d := "M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z", + clipRule := "evenodd" + ) + ) + } + ) + else emptyNode + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala new file mode 100644 index 0000000..1b41dee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/Form.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object Form: + val Body = FormBody + val Section = FormSection + val Row = FormRow + + def apply(body: HtmlElement, buttons: HtmlElement): HtmlElement = + form( + cls := "space-y-8 divide-y divide-gray-200", + body, + buttons + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala new file mode 100644 index 0000000..89abc9c --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormBody.scala @@ -0,0 +1,10 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormBody: + def apply(sections: HtmlElement*): HtmlElement = + div( + cls := "space-y-8 divide-y divide-gray-200 sm:space-y-5", + sections + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala new file mode 100644 index 0000000..a2e4cc6 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormFields.scala @@ -0,0 +1,14 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement +import org.scalajs.dom + +object FormFields: + def apply( + mods: Modifier[ReactiveHtmlElement[dom.HTMLElement]]* + ): HtmlElement = + div( + cls := "mt-6 sm:mt-5 space-y-6 sm:space-y-5", + mods + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala new file mode 100644 index 0000000..4083841 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormHeader.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +object FormHeader: + case class ViewModel(header: String, description: String) + def apply(m: ViewModel): HtmlElement = + div( + h3(cls := "text-lg leading-6 font-medium text-gray-900", m.header), + p(cls := "mt-1 max-w-2xl text-sm text-gray-500", m.description) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala new file mode 100644 index 0000000..ffc5807 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormRow.scala @@ -0,0 +1,22 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} + +case class FormRow(id: String, label: String, content: Modifier[Div]) + +object FormRow: + + extension (m: FormRow) + def toHtml: 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.id, + cls := "block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2", + m.label + ), + div( + cls := "mt-1 sm:mt-0 sm:col-span-2", + m.content + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala new file mode 100644 index 0000000..50811ee --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/form/FormSection.scala @@ -0,0 +1,15 @@ +package works.iterative.ui.components.tailwind.form + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.laminar.nodes.ReactiveHtmlElement + +object FormSection: + def apply( + header: HtmlElement, + rows: HtmlElement* + ): HtmlElement = + div( + cls := "space-y-6 sm:space-y-5", + header, + rows + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala new file mode 100644 index 0000000..66506e9 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/BaseList.scala @@ -0,0 +1,142 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import com.raquo.waypoint.Router +import com.raquo.laminar.builders.HtmlTag +import org.scalajs.dom + +trait ListItem: + def key: String + def title: Modifier[HtmlElement] + def topRight: Modifier[HtmlElement] + def bottomLeft: Modifier[HtmlElement] + def bottomRight: Modifier[HtmlElement] + +trait ListRenderable[Item]: + extension (x: Item) def asItem: ListItem + +trait Navigable[Item]: + extension (x: Item) def navigate: Modifier[HtmlElement] + +object BaseList: + enum Color: + case Green, Yellow, Red + + case class IconText(text: HtmlElement, icon: SvgElement) + case class Tag(text: String, color: Color) + case class Row( + id: String, + title: String, + tag: Tag, + leftProps: List[IconText], + rightProp: IconText + ) + + trait AsRow[Data]: + extension (d: Data) def asRow: Row + + class RowListItem(d: Row) extends ListItem: + + def key: String = d.id + + def title: Modifier[HtmlElement] = d.title + + def topRight: Modifier[HtmlElement] = + inline def colorClass(color: Color): (String, Boolean) = + val c = color.toString.toLowerCase + s"bg-$c-100 text-$c-800" -> (d.tag.color == color) + + inline def colors = Map(Color.values.map(colorClass(_)): _*) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls := colors, + d.tag.text + ) + + def bottomLeft: Modifier[HtmlElement] = + div( + cls := "sm:flex", + d.leftProps.zipWithIndex.map { case (i, idx) => + p( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)), + cls := "flex items-center text-sm text-gray-500", + i.icon, + i.text + ) + } + ) + + def bottomRight: Modifier[HtmlElement] = + div( + cls := "mt-2 flex items-center text-sm text-gray-500 sm:mt-0", + d.rightProp.icon, + d.rightProp.text + ) + + object Row: + given asRowRenderable[T: AsRow]: ListRenderable[T] with + extension (d: T) def asItem = new RowListItem(d.asRow) + + end Row + +class BaseList[RowData: ListRenderable]: + + protected def containerElement: HtmlTag[dom.html.Element] = div + protected def containerMods(rowData: RowData): Modifier[HtmlElement] = + emptyMod + protected def farRight: Modifier[HtmlElement] = emptyMod + + def render($data: Signal[List[RowData]]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $data.split(_.asItem.key)((_, d, _) => row(d)) + ) + + private def row(d: RowData): HtmlElement = + val data = d.asItem + li( + containerElement( + containerMods(d), + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + data.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + data.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + data.bottomLeft, + data.bottomRight + ) + ), + farRight + ) + ) + ) + +trait NavigableList[RowData: Navigable, Page](using router: Router[Page]) + extends BaseList[RowData]: + + override protected def containerElement: HtmlTag[dom.html.Element] = a + override protected def containerMods( + rowData: RowData + ): Modifier[HtmlElement] = + rowData.navigate + override protected def farRight: Modifier[HtmlElement] = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala new file mode 100644 index 0000000..9f5a7b1 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/IconText.scala @@ -0,0 +1,19 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom +import com.raquo.laminar.builders.HtmlTag + +object IconText: + case class ViewModel(text: HtmlElement, icon: SvgElement) + def render($m: Signal[ViewModel]): HtmlElement = render($m, div) + def render( + $m: Signal[ViewModel], + container: HtmlTag[dom.html.Element] + ): HtmlElement = + container( + cls := "flex items-center text-sm text-gray-500", + child <-- $m.map(_.icon), + child <-- $m.map(_.text) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala new file mode 100644 index 0000000..e5236a2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/ListRow.scala @@ -0,0 +1,47 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} +import org.scalajs.dom + +object ListRow: + case class ViewModel( + title: String, + topRight: Modifier[HtmlElement], + bottomLeft: Modifier[HtmlElement], + bottomRight: Modifier[HtmlElement], + farRight: Modifier[HtmlElement], + containerElement: HtmlElement = div() + ) + + def apply($m: Signal[ViewModel]): HtmlElement = + li( + child <-- $m.map(m => + m.containerElement.amend( + cls := "block hover:bg-gray-50", + div( + cls := "px-4 py-4 sm:px-6 items-center flex", + div( + cls := "min-w-0 flex-1 pr-4", + div( + cls := "flex items-center justify-between", + p( + cls := "text-sm font-medium text-indigo-600 truncate", + m.title + ), + div( + cls := "ml-2 flex-shrink-0 flex", + m.topRight + ) + ), + div( + cls := "mt-2 sm:flex sm:justify-between", + m.bottomLeft, + m.bottomRight + ) + ), + m.farRight + ) + ) + ) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala new file mode 100644 index 0000000..946ffaf --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/PropList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object PropList: + type ViewModel = List[HtmlElement] + def render($m: Signal[ViewModel]): HtmlElement = + div( + cls := "sm:flex", + children <-- $m.map(_.zipWithIndex.map { case (i, idx) => + i.amend( + cls := Map("mt-2 sm:mt-0 sm:ml-6" -> (idx == 0)) + ) + }) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala new file mode 100644 index 0000000..b53f3f2 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowNext.scala @@ -0,0 +1,11 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowNext: + def render: HtmlElement = + div( + cls := "flex-shrink-0", + Icons.solid.`chevron-right`().amend(svg.cls := "text-gray-400") + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala new file mode 100644 index 0000000..129abe4 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/RowTag.scala @@ -0,0 +1,17 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +object RowTag: + case class ViewModel(text: String, color: Color) + def render($m: Signal[ViewModel]): HtmlElement = + inline def colorClass(color: Color): Seq[String] = + import ColorWeight._ + List(color.bg(w100), color.text(w800)) + + p( + cls := "px-2 inline-flex text-xs leading-5 font-semibold rounded-full", + cls <-- $m.map(t => colorClass(t.color)), + child.text <-- $m.map(_.text) + ) diff --git a/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala new file mode 100644 index 0000000..6059362 --- /dev/null +++ b/ui/src/main/scala/fiftyforms/ui/components/tailwind/list/StackedList.scala @@ -0,0 +1,16 @@ +package works.iterative.ui.components.tailwind +package list + +import com.raquo.laminar.api.L.{*, given} + +class StackedList[Item]: + type ViewModel = List[Item] + def apply( + $m: Signal[ViewModel], + keyF: Item => String + )(f: Signal[Item] => Signal[ListRow.ViewModel]): HtmlElement = + ul( + role := "list", + cls := "divide-y divide-gray-200", + children <-- $m.split(keyF)((_, _, $d) => ListRow(f($d))) + )